From 10fd49f2696d04b022d7dda6f02d26d46bc02b33 Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Wed, 13 May 2026 13:51:00 -0500 Subject: [PATCH 01/46] add cross_year_matching_enabled column to partner per-partner toggle for cross-year student ID matching. defaults to false so existing partners are unchanged. temporary feature until an ID resolution service is in place. --- .../migrations/031.do.cross-year-matching-enabled.sql | 4 ++++ .../migrations/031.undo.cross-year-matching-enabled.sql | 1 + app/api/src/database/prisma/schema.prisma | 1 + 3 files changed, 6 insertions(+) create mode 100644 app/api/src/database/postgrator/migrations/031.do.cross-year-matching-enabled.sql create mode 100644 app/api/src/database/postgrator/migrations/031.undo.cross-year-matching-enabled.sql diff --git a/app/api/src/database/postgrator/migrations/031.do.cross-year-matching-enabled.sql b/app/api/src/database/postgrator/migrations/031.do.cross-year-matching-enabled.sql new file mode 100644 index 00000000..8b4acefc --- /dev/null +++ b/app/api/src/database/postgrator/migrations/031.do.cross-year-matching-enabled.sql @@ -0,0 +1,4 @@ +-- Per-partner toggle for cross-year student ID matching. +-- Temporary feature until ID resolution service exists; safe default is off. +ALTER TABLE partner + ADD COLUMN cross_year_matching_enabled BOOLEAN NOT NULL DEFAULT false; diff --git a/app/api/src/database/postgrator/migrations/031.undo.cross-year-matching-enabled.sql b/app/api/src/database/postgrator/migrations/031.undo.cross-year-matching-enabled.sql new file mode 100644 index 00000000..9367a8fb --- /dev/null +++ b/app/api/src/database/postgrator/migrations/031.undo.cross-year-matching-enabled.sql @@ -0,0 +1 @@ +ALTER TABLE partner DROP COLUMN cross_year_matching_enabled; diff --git a/app/api/src/database/prisma/schema.prisma b/app/api/src/database/prisma/schema.prisma index 0e50dc69..0cabb457 100644 --- a/app/api/src/database/prisma/schema.prisma +++ b/app/api/src/database/prisma/schema.prisma @@ -30,6 +30,7 @@ model Partner { modifiedOn DateTime @default(now()) @map("modified_on") @db.Timestamp(6) descriptorNamespace String? @map("descriptor_namespace") @db.VarChar idpId String? @map("idp_id") @db.VarChar + crossYearMatchingEnabled Boolean @default(false) @map("cross_year_matching_enabled") customDescriptorMapping CustomDescriptorMapping[] userPartnerCreatedByIdTouser User? @relation("partner_created_by_idTouser", fields: [createdById], references: [id], onUpdate: NoAction) identityProvider IdentityProvider? @relation(fields: [idpId], references: [id], onUpdate: NoAction) From 375cf11a7db29e6619ee037f38b3df54a2e69492 Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Wed, 13 May 2026 13:56:46 -0500 Subject: [PATCH 02/46] test: EDU snowflake config helpers on AppConfigService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit specifies eduCredsExist + getEduConnectionInfo. covers local env-var fallback path for development. AWS Secrets Manager path is exercised in deployed environments and not unit-tested here. failing — methods not yet implemented. --- .../context-fixtures/partner-fixtures.ts | 3 + app/api/integration/tests/edu-config.spec.ts | 76 +++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 app/api/integration/tests/edu-config.spec.ts diff --git a/app/api/integration/fixtures/context-fixtures/partner-fixtures.ts b/app/api/integration/fixtures/context-fixtures/partner-fixtures.ts index baa75139..652604b8 100644 --- a/app/api/integration/fixtures/context-fixtures/partner-fixtures.ts +++ b/app/api/integration/fixtures/context-fixtures/partner-fixtures.ts @@ -7,6 +7,7 @@ export const partnerA: WithoutAudit = { name: 'Partner A', idpId: idpA.id, descriptorNamespace: 'partner-a', + crossYearMatchingEnabled: false, }; export const partnerC: WithoutAudit = { @@ -14,6 +15,7 @@ export const partnerC: WithoutAudit = { name: 'Partner C', idpId: idpA.id, // shares idp with partner A descriptorNamespace: 'partner-c', + crossYearMatchingEnabled: false, }; export const partnerX: WithoutAudit = { @@ -21,4 +23,5 @@ export const partnerX: WithoutAudit = { name: 'Partner X', idpId: idpX.id, descriptorNamespace: null, + crossYearMatchingEnabled: false, }; diff --git a/app/api/integration/tests/edu-config.spec.ts b/app/api/integration/tests/edu-config.spec.ts new file mode 100644 index 00000000..34e051ed --- /dev/null +++ b/app/api/integration/tests/edu-config.spec.ts @@ -0,0 +1,76 @@ +import { AppConfigService } from 'api/src/config/app-config.service'; +import { partnerA } from '../fixtures/context-fixtures/partner-fixtures'; + +const EDU_ENV_VARS = [ + 'EDU_SNOWFLAKE_USERNAME', + 'EDU_SNOWFLAKE_URL', + 'EDU_SNOWFLAKE_PUBLIC_KEY', + 'EDU_SNOWFLAKE_PRIVATE_KEY', +] as const; + +describe('AppConfigService — EDU Snowflake config', () => { + let configService: AppConfigService; + const savedEnv: Record = {}; + + beforeAll(() => { + configService = app.get(AppConfigService); + }); + + beforeEach(() => { + for (const key of EDU_ENV_VARS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of EDU_ENV_VARS) { + if (savedEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } + } + }); + + describe('eduCredsExist', () => { + it('returns false when no env vars are set and no AWS secret exists', async () => { + const exists = await configService.eduCredsExist(partnerA.id); + expect(exists).toBe(false); + }); + + it('returns true when local env vars are set', async () => { + process.env.EDU_SNOWFLAKE_USERNAME = 'snowflake-user'; + process.env.EDU_SNOWFLAKE_URL = 'https://example.snowflakecomputing.com'; + process.env.EDU_SNOWFLAKE_PUBLIC_KEY = Buffer.from('public-key').toString('base64'); + process.env.EDU_SNOWFLAKE_PRIVATE_KEY = Buffer.from('private-key').toString('base64'); + + const exists = await configService.eduCredsExist(partnerA.id); + expect(exists).toBe(true); + }); + }); + + describe('getEduConnectionInfo', () => { + it('returns null when no env vars are set and no AWS secret exists', async () => { + const info = await configService.getEduConnectionInfo(partnerA.id); + expect(info).toBeNull(); + }); + + it('returns a connection info object built from env vars when set', async () => { + const privateKey = Buffer.from('private-key-content').toString('base64'); + const publicKey = Buffer.from('public-key-content').toString('base64'); + process.env.EDU_SNOWFLAKE_USERNAME = 'snowflake-user'; + process.env.EDU_SNOWFLAKE_URL = 'https://example.snowflakecomputing.com'; + process.env.EDU_SNOWFLAKE_PUBLIC_KEY = publicKey; + process.env.EDU_SNOWFLAKE_PRIVATE_KEY = privateKey; + + const info = await configService.getEduConnectionInfo(partnerA.id); + expect(info).toEqual({ + username: 'snowflake-user', + url: 'https://example.snowflakecomputing.com', + publicKey: Buffer.from('public-key-content'), + privateKey: Buffer.from('private-key-content'), + }); + }); + }); +}); From 3f8860bdab50b86a7ea1c0f77026867d6a52b579 Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Wed, 13 May 2026 13:57:35 -0500 Subject: [PATCH 03/46] implement EDU snowflake config helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getEduConnectionInfo reads from EDU_SNOWFLAKE_* env vars when present (local dev), otherwise looks up the AWS secret edu-connection-info-. returns null when nothing is available so callers can degrade gracefully — cross-year matching is best-effort, not required for a run to proceed. eduCredsExist is a boolean wrapper for use in payload assembly without leaking the creds themselves. --- app/api/src/config/app-config.service.ts | 56 ++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/app/api/src/config/app-config.service.ts b/app/api/src/config/app-config.service.ts index 746bbdb2..6d77e703 100644 --- a/app/api/src/config/app-config.service.ts +++ b/app/api/src/config/app-config.service.ts @@ -8,6 +8,13 @@ import { SSMClient, GetParametersCommand, Parameter } from '@aws-sdk/client-ssm' type ParameterWithNameAndValue = Required>; +export type EduConnectionInfo = { + username: string; + url: string; + publicKey: Buffer; + privateKey: Buffer; +}; + /** * AppConfigService is a wrapper on the @nestjs/config package's * ConfigService. It allows us to define custom getters, including @@ -96,6 +103,55 @@ export class AppConfigService { return this.get('JWT_ENCRYPTION_KEY'); } + /** + * EDU Snowflake connection info for cross-year ID matching. Looks for an + * AWS secret named `edu-connection-info-`; falls back to + * EDU_SNOWFLAKE_* env vars when the secret is unavailable (local dev). + * Returns null when no creds are available — caller decides how to handle. + */ + async getEduConnectionInfo(partnerId: string): Promise { + // Local-dev path first so tests / local runs never hit AWS. + const envUsername = process.env.EDU_SNOWFLAKE_USERNAME; + if (envUsername) { + const url = process.env.EDU_SNOWFLAKE_URL; + const publicKeyB64 = process.env.EDU_SNOWFLAKE_PUBLIC_KEY; + const privateKeyB64 = process.env.EDU_SNOWFLAKE_PRIVATE_KEY; + if (!url || !publicKeyB64 || !privateKeyB64) { + return null; + } + return { + username: envUsername, + url, + publicKey: Buffer.from(publicKeyB64, 'base64'), + privateKey: Buffer.from(privateKeyB64, 'base64'), + }; + } + + const secretName = `edu-connection-info-${partnerId}`; + try { + const secret = await this.getAWSSecret(secretName); + if (typeof secret !== 'object') { + return null; + } + const { username, url, publicKey, privateKey } = secret; + if (!username || !url || !publicKey || !privateKey) { + return null; + } + return { + username, + url, + publicKey: Buffer.from(publicKey, 'base64'), + privateKey: Buffer.from(privateKey, 'base64'), + }; + } catch { + return null; + } + } + + async eduCredsExist(partnerId: string): Promise { + return (await this.getEduConnectionInfo(partnerId)) !== null; + } + bundleBranch(): string { return this.get('BUNDLE_BRANCH') ?? 'main'; } From e12e39128c9e0384d0d2ad0022c57c73f04e28da Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Wed, 13 May 2026 13:58:40 -0500 Subject: [PATCH 04/46] test: cross-year payload flag and roster URL in earthbeam-api MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit specifies the contract between app and executor: - crossYearMatchAvailable is always present - when toggle on AND creds exist: flag=true, appUrls.roster set - when either is missing: flag=false, no roster URL failing — payload assembly doesn't read the toggle yet. --- .../integration/tests/earthbeam-api.spec.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/app/api/integration/tests/earthbeam-api.spec.ts b/app/api/integration/tests/earthbeam-api.spec.ts index 57a428e2..4a66a4aa 100644 --- a/app/api/integration/tests/earthbeam-api.spec.ts +++ b/app/api/integration/tests/earthbeam-api.spec.ts @@ -80,6 +80,86 @@ describe('Earthbeam API', () => { expect(res.body.rosterFilePath).toBeUndefined(); }); + describe('cross-year ID matching', () => { + const EDU_ENV_VARS = [ + 'EDU_SNOWFLAKE_USERNAME', + 'EDU_SNOWFLAKE_URL', + 'EDU_SNOWFLAKE_PUBLIC_KEY', + 'EDU_SNOWFLAKE_PRIVATE_KEY', + ] as const; + const savedEnv: Record = {}; + + const setEduEnvVars = () => { + process.env.EDU_SNOWFLAKE_USERNAME = 'snowflake-user'; + process.env.EDU_SNOWFLAKE_URL = 'https://example.snowflakecomputing.com'; + process.env.EDU_SNOWFLAKE_PUBLIC_KEY = Buffer.from('pub').toString('base64'); + process.env.EDU_SNOWFLAKE_PRIVATE_KEY = Buffer.from('priv').toString('base64'); + }; + + beforeEach(() => { + for (const key of EDU_ENV_VARS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of EDU_ENV_VARS) { + if (savedEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } + } + }); + + it('sets crossYearMatchAvailable=true and emits appUrls.roster when toggle on and creds exist', async () => { + await global.prisma.partner.update({ + where: { id: partnerA.id }, + data: { crossYearMatchingEnabled: true }, + }); + setEduEnvVars(); + + const res = await request(app.getHttpServer()) + .get(endpointA) + .set('Authorization', `Bearer ${tokenA}`); + + expect(res.status).toBe(200); + expect(res.body.crossYearMatchAvailable).toBe(true); + expect(res.body.appUrls.roster).toBeDefined(); + expect(res.body.appUrls.roster).toContain(`/earthbeam/jobs/${runA.id}/roster`); + }); + + it('sets crossYearMatchAvailable=false and omits appUrls.roster when toggle is off', async () => { + // partnerA defaults to crossYearMatchingEnabled=false + setEduEnvVars(); + + const res = await request(app.getHttpServer()) + .get(endpointA) + .set('Authorization', `Bearer ${tokenA}`); + + expect(res.status).toBe(200); + expect(res.body.crossYearMatchAvailable).toBe(false); + expect(res.body.appUrls.roster).toBeUndefined(); + }); + + it('sets crossYearMatchAvailable=false and omits appUrls.roster when creds are missing', async () => { + await global.prisma.partner.update({ + where: { id: partnerA.id }, + data: { crossYearMatchingEnabled: true }, + }); + // env vars deliberately not set + + const res = await request(app.getHttpServer()) + .get(endpointA) + .set('Authorization', `Bearer ${tokenA}`); + + expect(res.status).toBe(200); + expect(res.body.crossYearMatchAvailable).toBe(false); + expect(res.body.appUrls.roster).toBeUndefined(); + }); + }); + it('should omit ODS credentials and include a roster path for no-ODS jobs', async () => { const authService = app.get(EarthbeamApiAuthService); const noOdsJob = await seedJob({ From c387ff8e8cf06dfcf719f302b145712b4b5f0bed Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Wed, 13 May 2026 14:01:02 -0500 Subject: [PATCH 05/46] expose cross-year roster availability to the executor EarthbeamApiJobResponseDto gains crossYearMatchAvailable (always present, boolean) and an optional appUrls.roster. the flag is true iff the partner has cross-year matching enabled AND EDU creds are available; otherwise jobs proceed without cross-year matching. the roster URL is omitted entirely when the flag is false so the executor can't accidentally call a disabled endpoint. --- app/api/src/earthbeam/api/earthbeam-api.endpoints.ts | 3 +++ app/api/src/earthbeam/api/earthbeam-api.service.ts | 10 ++++++++++ app/models/src/dtos/earthbeam-api.dto.ts | 4 ++++ 3 files changed, 17 insertions(+) diff --git a/app/api/src/earthbeam/api/earthbeam-api.endpoints.ts b/app/api/src/earthbeam/api/earthbeam-api.endpoints.ts index 12dd302b..1c64da1d 100644 --- a/app/api/src/earthbeam/api/earthbeam-api.endpoints.ts +++ b/app/api/src/earthbeam/api/earthbeam-api.endpoints.ts @@ -21,3 +21,6 @@ export const earthbeamUnmatchedIdsEndpoint = (runId: number | ':runId') => export const earthbeamOutputFilesEndpoint = (runId: number | ':runId') => `api/${EARTHBEAM_API_BASE_ROUTE}/${runId}/output-files`; + +export const earthbeamRosterEndpoint = (runId: number | ':runId') => + `api/${EARTHBEAM_API_BASE_ROUTE}/${runId}/roster`; diff --git a/app/api/src/earthbeam/api/earthbeam-api.service.ts b/app/api/src/earthbeam/api/earthbeam-api.service.ts index a0f97fea..ce141e43 100644 --- a/app/api/src/earthbeam/api/earthbeam-api.service.ts +++ b/app/api/src/earthbeam/api/earthbeam-api.service.ts @@ -17,6 +17,7 @@ import { plainToInstance } from 'class-transformer'; import { earthbeamErrorUpdateEndpoint, earthbeamOutputFilesEndpoint, + earthbeamRosterEndpoint, earthbeamStatusUpdateEndpoint, earthbeamSummaryEndpoint, earthbeamUnmatchedIdsEndpoint, @@ -140,6 +141,11 @@ export class EarthbeamApiService { const executorBaseUrl = this.configService.executorCallbackBaseUrl(); + const partnerId = job.tenant.partnerId; + const crossYearMatchAvailable = + job.tenant.partner.crossYearMatchingEnabled && + (await this.configService.eduCredsExist(partnerId)); + const payload: EarthbeamApiJobResponseDto = { appDataBasePath: `${job.fileProtocol}://${job.fileBucketOrHost}/${job.fileBasePath}`, inputFiles: filesForEarthbeam, @@ -158,7 +164,11 @@ export class EarthbeamApiService { summary: `${executorBaseUrl}/${earthbeamSummaryEndpoint(runId)}`, unmatchedIds: `${executorBaseUrl}/${earthbeamUnmatchedIdsEndpoint(runId)}`, outputFiles: `${executorBaseUrl}/${earthbeamOutputFilesEndpoint(runId)}`, + ...(crossYearMatchAvailable + ? { roster: `${executorBaseUrl}/${earthbeamRosterEndpoint(runId)}` } + : {}), }, + crossYearMatchAvailable, sendToOds: job.sendToOds, rosterFilePath: job.sendToOds ? undefined diff --git a/app/models/src/dtos/earthbeam-api.dto.ts b/app/models/src/dtos/earthbeam-api.dto.ts index f4501bdc..7236f5f2 100644 --- a/app/models/src/dtos/earthbeam-api.dto.ts +++ b/app/models/src/dtos/earthbeam-api.dto.ts @@ -44,11 +44,15 @@ export class EarthbeamApiJobResponseDto { summary: string; unmatchedIds: string; outputFiles: string; + roster?: string; }; @Expose() sendToOds: boolean; + @Expose() + crossYearMatchAvailable: boolean; + @Expose() rosterFilePath?: string; From 132d20c8c74a525dda0ab4f9824d978931ae9cee Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Wed, 13 May 2026 14:07:13 -0500 Subject: [PATCH 06/46] test: roster endpoint behaviors specifies the streaming roster endpoint: - 401 for unauthenticated requests (guard) - 409 when toggle off or creds missing - 200 + application/x-ndjson when both prerequisites are met - abrupt close on mid-stream errors (no in-band sentinel) the service-level streamCrossYearRoster stub is in place so the controller can be wired and the test suite compiles; implementation lands in the green step. --- .../integration/tests/earthbeam-api.spec.ts | 142 ++++++++++++++++++ .../earthbeam/api/earthbeam-api.service.ts | 8 + 2 files changed, 150 insertions(+) diff --git a/app/api/integration/tests/earthbeam-api.spec.ts b/app/api/integration/tests/earthbeam-api.spec.ts index 4a66a4aa..2ca0bf08 100644 --- a/app/api/integration/tests/earthbeam-api.spec.ts +++ b/app/api/integration/tests/earthbeam-api.spec.ts @@ -1,4 +1,5 @@ import { EarthbeamApiAuthService } from 'api/src/earthbeam/api/auth/earthbeam-api-auth.service'; +import { EarthbeamApiService } from 'api/src/earthbeam/api/earthbeam-api.service'; import request from 'supertest'; import { seedJob } from '../factories/job-factory'; import { bundleA, bundleX } from '../fixtures/em-bundle-fixtures'; @@ -311,6 +312,147 @@ describe('Earthbeam API', () => { }); + describe('GET /:runId/roster', () => { + let runA: Run; + let endpointA: string; + let tokenA: string; + let streamSpy: jest.SpyInstance | undefined; + + const EDU_ENV_VARS = [ + 'EDU_SNOWFLAKE_USERNAME', + 'EDU_SNOWFLAKE_URL', + 'EDU_SNOWFLAKE_PUBLIC_KEY', + 'EDU_SNOWFLAKE_PRIVATE_KEY', + ] as const; + const savedEnv: Record = {}; + + const setEduEnvVars = () => { + process.env.EDU_SNOWFLAKE_USERNAME = 'snowflake-user'; + process.env.EDU_SNOWFLAKE_URL = 'https://example.snowflakecomputing.com'; + process.env.EDU_SNOWFLAKE_PUBLIC_KEY = Buffer.from('pub').toString('base64'); + process.env.EDU_SNOWFLAKE_PRIVATE_KEY = Buffer.from('priv').toString('base64'); + }; + + beforeEach(async () => { + for (const key of EDU_ENV_VARS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + streamSpy = undefined; + + const authService = app.get(EarthbeamApiAuthService); + const jobA = await seedJob({ + odsConfig: odsConfigA2425, + bundle: bundleA, + tenant: tenantA, + }); + runA = jobA.runs[0]; + endpointA = `/earthbeam/jobs/${runA.id}/roster`; + tokenA = await authService.createAccessToken({ runId: runA.id }); + }); + + afterEach(() => { + for (const key of EDU_ENV_VARS) { + if (savedEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } + } + streamSpy?.mockRestore(); + }); + + it('rejects unauthenticated requests', async () => { + const res = await request(app.getHttpServer()).get(endpointA); + expect(res.status).toBe(401); + }); + + it('returns 409 when the partner has cross-year matching disabled', async () => { + // partnerA defaults to crossYearMatchingEnabled=false + setEduEnvVars(); + const res = await request(app.getHttpServer()) + .get(endpointA) + .set('Authorization', `Bearer ${tokenA}`); + expect(res.status).toBe(409); + }); + + it('returns 409 when EDU creds are missing', async () => { + await global.prisma.partner.update({ + where: { id: partnerA.id }, + data: { crossYearMatchingEnabled: true }, + }); + // env vars deliberately not set + const res = await request(app.getHttpServer()) + .get(endpointA) + .set('Authorization', `Bearer ${tokenA}`); + expect(res.status).toBe(409); + }); + + it('streams NDJSON rows when toggle on and creds present', async () => { + await global.prisma.partner.update({ + where: { id: partnerA.id }, + data: { crossYearMatchingEnabled: true }, + }); + setEduEnvVars(); + + const earthbeamApiService = app.get(EarthbeamApiService); + const rows = [ + { studentUniqueId: '1', priorYear: 2024 }, + { studentUniqueId: '2', priorYear: 2024 }, + { studentUniqueId: '3', priorYear: 2024 }, + ]; + streamSpy = jest + .spyOn(earthbeamApiService, 'streamCrossYearRoster') + .mockImplementation(async ({ response }) => { + for (const row of rows) { + response.write(JSON.stringify(row) + '\n'); + } + response.end(); + }); + + const res = await request(app.getHttpServer()) + .get(endpointA) + .set('Authorization', `Bearer ${tokenA}`); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toContain('application/x-ndjson'); + const lines = res.text.split('\n').filter((l) => l.length > 0); + expect(lines).toHaveLength(3); + expect(JSON.parse(lines[0])).toEqual(rows[0]); + expect(JSON.parse(lines[2])).toEqual(rows[2]); + + expect(streamSpy).toHaveBeenCalledWith( + expect.objectContaining({ + partnerId: partnerA.id, + tenantCode: tenantA.code, + }) + ); + }); + + it('closes the response abruptly when streaming errors mid-flight', async () => { + await global.prisma.partner.update({ + where: { id: partnerA.id }, + data: { crossYearMatchingEnabled: true }, + }); + setEduEnvVars(); + + const earthbeamApiService = app.get(EarthbeamApiService); + streamSpy = jest + .spyOn(earthbeamApiService, 'streamCrossYearRoster') + .mockImplementation(async ({ response }) => { + response.write(JSON.stringify({ studentUniqueId: '1' }) + '\n'); + response.destroy(new Error('snowflake exploded')); + }); + + // supertest surfaces a destroyed socket as an error; the response should + // not contain a sentinel error line — we just abort. + await request(app.getHttpServer()) + .get(endpointA) + .set('Authorization', `Bearer ${tokenA}`) + .catch((err) => err); // socket close raises; we don't care about the shape + }); + }); + describe('POST /:runId/status', () => { let runA: Run; let tokenA: string; diff --git a/app/api/src/earthbeam/api/earthbeam-api.service.ts b/app/api/src/earthbeam/api/earthbeam-api.service.ts index ce141e43..5ccbde70 100644 --- a/app/api/src/earthbeam/api/earthbeam-api.service.ts +++ b/app/api/src/earthbeam/api/earthbeam-api.service.ts @@ -43,6 +43,14 @@ export class EarthbeamApiService { @Inject(EVENT_EMITTER_SERVICE) private readonly eventEmitter: EventEmitterService ) {} + async streamCrossYearRoster(_args: { + partnerId: string; + tenantCode: string; + response: import('express').Response; + }): Promise { + throw new Error('streamCrossYearRoster not implemented'); + } + async earthbeamInputForRun(runId: Run['id']) { const run = await this.prisma.run.findUnique({ where: { id: runId }, From ece14a266ba080ef1ce7fe2efea9429c0b76810b Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Wed, 13 May 2026 14:37:49 -0500 Subject: [PATCH 07/46] implement cross-year roster streaming endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /earthbeam/jobs/:runId/roster streams the partner's cross-year roster to the executor as NDJSON. Reuses the existing executor JWT guard; returns 409 if the toggle is off or EDU creds are missing. The Snowflake client is lazy-imported on first use — snowflake-sdk has slow module-init side effects we shouldn't pay on every app boot, and cross-year fetches are a rare hot path. Connection is per-request and closed in finally. Mid-stream errors abort the response (no in-band sentinel); the executor detects truncation and fails the run. The query is a TODO placeholder until the data engineer supplies the real one — controller wiring, auth, and error semantics are all in place so the swap is one-line. --- .../factories/partner-user-tenant.ts | 1 + .../earthbeam/api/earthbeam-api.controller.ts | 27 + .../earthbeam/api/earthbeam-api.service.ts | 101 +- app/package-lock.json | 2587 ++++++++++++----- app/package.json | 1 + 5 files changed, 1914 insertions(+), 803 deletions(-) diff --git a/app/api/integration/factories/partner-user-tenant.ts b/app/api/integration/factories/partner-user-tenant.ts index 209d9eeb..c6da0d86 100644 --- a/app/api/integration/factories/partner-user-tenant.ts +++ b/app/api/integration/factories/partner-user-tenant.ts @@ -13,6 +13,7 @@ export const makePartnerUserTenantContext = (tag: string) => { name: `Partner ${tag}`, idpId: idp.id, descriptorNamespace: null, + crossYearMatchingEnabled: false, }; const tenant: WithoutAudit = { diff --git a/app/api/src/earthbeam/api/earthbeam-api.controller.ts b/app/api/src/earthbeam/api/earthbeam-api.controller.ts index 424c5308..4ba66dbb 100644 --- a/app/api/src/earthbeam/api/earthbeam-api.controller.ts +++ b/app/api/src/earthbeam/api/earthbeam-api.controller.ts @@ -13,8 +13,10 @@ import { ParseIntPipe, Post, Req, + Res, UseGuards, } from '@nestjs/common'; +import type { Response } from 'express'; import { ApiTags } from '@nestjs/swagger'; import { makeEarthbeamJWTGuard } from './auth/earthbeam-api-auth.guard'; import { Public } from 'api/src/auth/login/public.decorator'; @@ -72,6 +74,31 @@ export class EarthbeamApiController { return toEarthbeamApiJobResponseDto(result.data); } + @Get(':runId/roster') + async streamRoster(@Param('runId', ParseIntPipe) runId: number, @Res() res: Response) { + const ctx = await this.earthbeamApiService.getCrossYearRosterContext(runId); + if (ctx.status === 'ERROR') { + if (ctx.type === 'not_found') throw new NotFoundException(ctx.message); + throw new ConflictException(ctx.message); + } + + res.setHeader('Content-Type', 'application/x-ndjson'); + try { + await this.earthbeamApiService.streamCrossYearRoster({ + partnerId: ctx.data.partnerId, + tenantCode: ctx.data.tenantCode, + response: res, + }); + } catch (err) { + this.logger.error( + `cross-year roster fetch failed for run ${runId}: ${err instanceof Error ? err.message : String(err)}` + ); + if (!res.destroyed) { + res.destroy(err instanceof Error ? err : new Error(String(err))); + } + } + } + @Post(':runId/status') async updateStatus( @Param('runId', ParseIntPipe) runId: number, diff --git a/app/api/src/earthbeam/api/earthbeam-api.service.ts b/app/api/src/earthbeam/api/earthbeam-api.service.ts index 5ccbde70..193ec8b4 100644 --- a/app/api/src/earthbeam/api/earthbeam-api.service.ts +++ b/app/api/src/earthbeam/api/earthbeam-api.service.ts @@ -30,6 +30,7 @@ import { EventEmitterService, EVENT_EMITTER_SERVICE, } from 'api/src/event-emitter/event-emitter.service'; +import type { Response } from 'express'; @Injectable() export class EarthbeamApiService { @@ -43,12 +44,106 @@ export class EarthbeamApiService { @Inject(EVENT_EMITTER_SERVICE) private readonly eventEmitter: EventEmitterService ) {} - async streamCrossYearRoster(_args: { + async getCrossYearRosterContext(runId: Run['id']) { + const run = await this.prisma.run.findUnique({ + where: { id: runId }, + include: { job: { include: { tenant: { include: { partner: true } } } } }, + }); + if (!run) { + return { status: 'ERROR' as const, type: 'not_found' as const, message: `Run not found: ${runId}` }; + } + const partner = run.job.tenant.partner; + if (!partner.crossYearMatchingEnabled) { + return { + status: 'ERROR' as const, + type: 'conflict' as const, + message: 'Cross-year matching is not enabled for this partner', + }; + } + if (!(await this.configService.eduCredsExist(partner.id))) { + return { + status: 'ERROR' as const, + type: 'conflict' as const, + message: 'EDU connection info is not available for this partner', + }; + } + return { + status: 'SUCCESS' as const, + data: { partnerId: partner.id, tenantCode: run.job.tenant.code }, + }; + } + + /** + * Streams a cross-year roster from EDU/Snowflake to the response as NDJSON. + * Per-request connection, closed in `finally`. On stream error: abrupt close + * (no in-band sentinel) — the Executor detects truncation and fails the run. + */ + async streamCrossYearRoster({ + partnerId, + tenantCode, + response, + }: { partnerId: string; tenantCode: string; - response: import('express').Response; + response: Response; }): Promise { - throw new Error('streamCrossYearRoster not implemented'); + const conn = await this.configService.getEduConnectionInfo(partnerId); + if (!conn) { + throw new Error('EDU connection info missing — caller should have validated'); + } + + // Lazy import: snowflake-sdk has slow module-init side effects we don't want + // to pay on every app boot. Cross-year roster fetch is a rare hot path. + const snowflake = await import('snowflake-sdk'); + + // snowflake-sdk wants `account`, not a URL. URL is like + // https://..snowflakecomputing.com — take the leading subdomain. + const account = new URL(conn.url).hostname.split('.')[0]; + const connection = snowflake.createConnection({ + account, + username: conn.username, + authenticator: 'SNOWFLAKE_JWT', + privateKey: conn.privateKey.toString('utf-8'), + }); + + const startedAt = Date.now(); + let rowCount = 0; + try { + await new Promise((resolve, reject) => { + connection.connect((err) => (err ? reject(err) : resolve())); + }); + + // TODO: replace with data-engineer-supplied query. Must accept tenant_code + // as its single bind parameter and emit JSON-shaped rows. + const sqlText = 'SELECT :1 AS tenant_code /* TODO: real cross-year roster query */'; + + await new Promise((resolve, reject) => { + const stmt = connection.execute({ + sqlText, + binds: [tenantCode], + streamResult: true, + }); + const rowStream = stmt.streamRows(); + rowStream.on('data', (row) => { + response.write(JSON.stringify(row) + '\n'); + rowCount += 1; + }); + rowStream.on('end', () => { + response.end(); + resolve(); + }); + rowStream.on('error', (err) => { + response.destroy(err); + reject(err); + }); + }); + + this.logger.log( + `cross-year roster: partnerId=${partnerId} tenantCode=${tenantCode} rowCount=${rowCount} durationMs=${Date.now() - startedAt}` + ); + } finally { + connection.destroy(() => undefined); + } } async earthbeamInputForRun(runId: Run['id']) { diff --git a/app/package-lock.json b/app/package-lock.json index caf426ff..53c765da 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -67,6 +67,7 @@ "react-icons": "^5.2.1", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.0", + "snowflake-sdk": "^2.4.1", "tslib": "^2.3.0" }, "devDependencies": { @@ -621,65 +622,98 @@ } }, "node_modules/@aws-sdk/client-s3": { - "version": "3.995.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.995.0.tgz", - "integrity": "sha512-r+t8qrQ0m9zoovYOH+wilp/glFRB/E+blsDyWzq2C+9qmyhCAQwaxjLaHM8T/uluAmhtZQIYqOH9ILRnvWtRNw==", + "version": "3.1045.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1045.0.tgz", + "integrity": "sha512-fsuO3Y6t+3Ro9Bsg41DKj4Sfy53CGSrhnMldNplWmG8Tx0UbYk+YDa4RD1hVlJpERw4JBmPkl0+J9qlxMh1pcA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.11", - "@aws-sdk/credential-provider-node": "^3.972.10", - "@aws-sdk/middleware-bucket-endpoint": "^3.972.3", - "@aws-sdk/middleware-expect-continue": "^3.972.3", - "@aws-sdk/middleware-flexible-checksums": "^3.972.9", - "@aws-sdk/middleware-host-header": "^3.972.3", - "@aws-sdk/middleware-location-constraint": "^3.972.3", - "@aws-sdk/middleware-logger": "^3.972.3", - "@aws-sdk/middleware-recursion-detection": "^3.972.3", - "@aws-sdk/middleware-sdk-s3": "^3.972.11", - "@aws-sdk/middleware-ssec": "^3.972.3", - "@aws-sdk/middleware-user-agent": "^3.972.11", - "@aws-sdk/region-config-resolver": "^3.972.3", - "@aws-sdk/signature-v4-multi-region": "3.995.0", - "@aws-sdk/types": "^3.973.1", - "@aws-sdk/util-endpoints": "3.995.0", - "@aws-sdk/util-user-agent-browser": "^3.972.3", - "@aws-sdk/util-user-agent-node": "^3.972.10", - "@smithy/config-resolver": "^4.4.6", - "@smithy/core": "^3.23.2", - "@smithy/eventstream-serde-browser": "^4.2.8", - "@smithy/eventstream-serde-config-resolver": "^4.3.8", - "@smithy/eventstream-serde-node": "^4.2.8", - "@smithy/fetch-http-handler": "^5.3.9", - "@smithy/hash-blob-browser": "^4.2.9", - "@smithy/hash-node": "^4.2.8", - "@smithy/hash-stream-node": "^4.2.8", - "@smithy/invalid-dependency": "^4.2.8", - "@smithy/md5-js": "^4.2.8", - "@smithy/middleware-content-length": "^4.2.8", - "@smithy/middleware-endpoint": "^4.4.16", - "@smithy/middleware-retry": "^4.4.33", - "@smithy/middleware-serde": "^4.2.9", - "@smithy/middleware-stack": "^4.2.8", - "@smithy/node-config-provider": "^4.3.8", - "@smithy/node-http-handler": "^4.4.10", - "@smithy/protocol-http": "^5.3.8", - "@smithy/smithy-client": "^4.11.5", - "@smithy/types": "^4.12.0", - "@smithy/url-parser": "^4.2.8", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-body-length-node": "^4.2.1", - "@smithy/util-defaults-mode-browser": "^4.3.32", - "@smithy/util-defaults-mode-node": "^4.2.35", - "@smithy/util-endpoints": "^3.2.8", - "@smithy/util-middleware": "^4.2.8", - "@smithy/util-retry": "^4.2.8", - "@smithy/util-stream": "^4.5.12", - "@smithy/util-utf8": "^4.2.0", - "@smithy/util-waiter": "^4.2.8", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/credential-provider-node": "^3.972.39", + "@aws-sdk/middleware-bucket-endpoint": "^3.972.10", + "@aws-sdk/middleware-expect-continue": "^3.972.10", + "@aws-sdk/middleware-flexible-checksums": "^3.974.16", + "@aws-sdk/middleware-host-header": "^3.972.10", + "@aws-sdk/middleware-location-constraint": "^3.972.10", + "@aws-sdk/middleware-logger": "^3.972.10", + "@aws-sdk/middleware-recursion-detection": "^3.972.11", + "@aws-sdk/middleware-sdk-s3": "^3.972.37", + "@aws-sdk/middleware-ssec": "^3.972.10", + "@aws-sdk/middleware-user-agent": "^3.972.38", + "@aws-sdk/region-config-resolver": "^3.972.13", + "@aws-sdk/signature-v4-multi-region": "^3.996.25", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-endpoints": "^3.996.8", + "@aws-sdk/util-user-agent-browser": "^3.972.10", + "@aws-sdk/util-user-agent-node": "^3.973.24", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", + "@smithy/eventstream-serde-browser": "^4.2.14", + "@smithy/eventstream-serde-config-resolver": "^4.3.14", + "@smithy/eventstream-serde-node": "^4.2.14", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/hash-blob-browser": "^4.2.15", + "@smithy/hash-node": "^4.2.14", + "@smithy/hash-stream-node": "^4.2.14", + "@smithy/invalid-dependency": "^4.2.14", + "@smithy/md5-js": "^4.2.14", + "@smithy/middleware-content-length": "^4.2.14", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.7", + "@smithy/middleware-serde": "^4.2.20", + "@smithy/middleware-stack": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.6", + "@smithy/util-stream": "^4.5.25", + "@smithy/util-utf8": "^4.2.2", + "@smithy/util-waiter": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.25", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.25.tgz", + "integrity": "sha512-+CMIt3e1VzlklAECmG+DtP1sV8iKq25FuA0OKpnJ4KA0kxUtd7CgClY7/RU6VzJBQwbN4EJ9Ue6plvqx1qGadw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "^3.972.37", + "@aws-sdk/types": "^3.973.8", + "@smithy/protocol-http": "^5.3.14", + "@smithy/signature-v4": "^5.3.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.8.tgz", + "integrity": "sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-endpoints": "^3.4.2", "tslib": "^2.6.2" }, "engines": { @@ -787,115 +821,84 @@ "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/client-sso": { - "version": "3.993.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.993.0.tgz", - "integrity": "sha512-VLUN+wIeNX24fg12SCbzTUBnBENlL014yMKZvRhPkcn4wHR6LKgNrjsG3fZ03Xs0XoKaGtNFi1VVrq666sGBoQ==", + "node_modules/@aws-sdk/client-sts": { + "version": "3.1045.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.1045.0.tgz", + "integrity": "sha512-oDJJ7rM1osvfBdfZuhQ5DM6lHD9iuypL9m2LsEiA/lB8xuE5uPYsftNDcS0J9VRXFSvYTqC14K7Y5vMMKMg0vw==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.11", - "@aws-sdk/middleware-host-header": "^3.972.3", - "@aws-sdk/middleware-logger": "^3.972.3", - "@aws-sdk/middleware-recursion-detection": "^3.972.3", - "@aws-sdk/middleware-user-agent": "^3.972.11", - "@aws-sdk/region-config-resolver": "^3.972.3", - "@aws-sdk/types": "^3.973.1", - "@aws-sdk/util-endpoints": "3.993.0", - "@aws-sdk/util-user-agent-browser": "^3.972.3", - "@aws-sdk/util-user-agent-node": "^3.972.9", - "@smithy/config-resolver": "^4.4.6", - "@smithy/core": "^3.23.2", - "@smithy/fetch-http-handler": "^5.3.9", - "@smithy/hash-node": "^4.2.8", - "@smithy/invalid-dependency": "^4.2.8", - "@smithy/middleware-content-length": "^4.2.8", - "@smithy/middleware-endpoint": "^4.4.16", - "@smithy/middleware-retry": "^4.4.33", - "@smithy/middleware-serde": "^4.2.9", - "@smithy/middleware-stack": "^4.2.8", - "@smithy/node-config-provider": "^4.3.8", - "@smithy/node-http-handler": "^4.4.10", - "@smithy/protocol-http": "^5.3.8", - "@smithy/smithy-client": "^4.11.5", - "@smithy/types": "^4.12.0", - "@smithy/url-parser": "^4.2.8", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-body-length-node": "^4.2.1", - "@smithy/util-defaults-mode-browser": "^4.3.32", - "@smithy/util-defaults-mode-node": "^4.2.35", - "@smithy/util-endpoints": "^3.2.8", - "@smithy/util-middleware": "^4.2.8", - "@smithy/util-retry": "^4.2.8", - "@smithy/util-utf8": "^4.2.0", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/credential-provider-node": "^3.972.39", + "@aws-sdk/middleware-host-header": "^3.972.10", + "@aws-sdk/middleware-logger": "^3.972.10", + "@aws-sdk/middleware-recursion-detection": "^3.972.11", + "@aws-sdk/middleware-user-agent": "^3.972.38", + "@aws-sdk/region-config-resolver": "^3.972.13", + "@aws-sdk/signature-v4-multi-region": "^3.996.25", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-endpoints": "^3.996.8", + "@aws-sdk/util-user-agent-browser": "^3.972.10", + "@aws-sdk/util-user-agent-node": "^3.973.24", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/hash-node": "^4.2.14", + "@smithy/invalid-dependency": "^4.2.14", + "@smithy/middleware-content-length": "^4.2.14", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.7", + "@smithy/middleware-serde": "^4.2.20", + "@smithy/middleware-stack": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.6", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/client-sso/node_modules/@aws-sdk/util-endpoints": { - "version": "3.993.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.993.0.tgz", - "integrity": "sha512-j6vioBeRZ4eHX4SWGvGPpwGg/xSOcK7f1GL0VM+rdf3ZFTIsUEhCFmD78B+5r2PgztcECSzEfvHQX01k8dPQPw==", + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.25", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.25.tgz", + "integrity": "sha512-+CMIt3e1VzlklAECmG+DtP1sV8iKq25FuA0OKpnJ4KA0kxUtd7CgClY7/RU6VzJBQwbN4EJ9Ue6plvqx1qGadw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.1", - "@smithy/types": "^4.12.0", - "@smithy/url-parser": "^4.2.8", - "@smithy/util-endpoints": "^3.2.8", + "@aws-sdk/middleware-sdk-s3": "^3.972.37", + "@aws-sdk/types": "^3.973.8", + "@smithy/protocol-http": "^5.3.14", + "@smithy/signature-v4": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/client-sts": { - "version": "3.995.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.995.0.tgz", - "integrity": "sha512-irF0VzMvPetJCIVgJ7lyiTyT1ERukNZLYzk+rwlOapWEODFSxqMcw/lAebPZSWxN/t7uec6cYGDyt/hlVoVpdQ==", + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.8.tgz", + "integrity": "sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.11", - "@aws-sdk/credential-provider-node": "^3.972.10", - "@aws-sdk/middleware-host-header": "^3.972.3", - "@aws-sdk/middleware-logger": "^3.972.3", - "@aws-sdk/middleware-recursion-detection": "^3.972.3", - "@aws-sdk/middleware-user-agent": "^3.972.11", - "@aws-sdk/region-config-resolver": "^3.972.3", - "@aws-sdk/types": "^3.973.1", - "@aws-sdk/util-endpoints": "3.995.0", - "@aws-sdk/util-user-agent-browser": "^3.972.3", - "@aws-sdk/util-user-agent-node": "^3.972.10", - "@smithy/config-resolver": "^4.4.6", - "@smithy/core": "^3.23.2", - "@smithy/fetch-http-handler": "^5.3.9", - "@smithy/hash-node": "^4.2.8", - "@smithy/invalid-dependency": "^4.2.8", - "@smithy/middleware-content-length": "^4.2.8", - "@smithy/middleware-endpoint": "^4.4.16", - "@smithy/middleware-retry": "^4.4.33", - "@smithy/middleware-serde": "^4.2.9", - "@smithy/middleware-stack": "^4.2.8", - "@smithy/node-config-provider": "^4.3.8", - "@smithy/node-http-handler": "^4.4.10", - "@smithy/protocol-http": "^5.3.8", - "@smithy/smithy-client": "^4.11.5", - "@smithy/types": "^4.12.0", - "@smithy/url-parser": "^4.2.8", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-body-length-node": "^4.2.1", - "@smithy/util-defaults-mode-browser": "^4.3.32", - "@smithy/util-defaults-mode-node": "^4.2.35", - "@smithy/util-endpoints": "^3.2.8", - "@smithy/util-middleware": "^4.2.8", - "@smithy/util-retry": "^4.2.8", - "@smithy/util-utf8": "^4.2.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-endpoints": "^3.4.2", "tslib": "^2.6.2" }, "engines": { @@ -903,23 +906,24 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.973.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.11.tgz", - "integrity": "sha512-wdQ8vrvHkKIV7yNUKXyjPWKCdYEUrZTHJ8Ojd5uJxXp9vqPCkUR1dpi1NtOLcrDgueJH7MUH5lQZxshjFPSbDA==", + "version": "3.974.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.8.tgz", + "integrity": "sha512-njR2qoG6ZuB0kvAS2FyICsFZJ6gmCcf2X/7JcD14sUvGDm26wiZ5BrA6LOiUxKFEF+IVe7kdroxyE00YlkiYsw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.1", - "@aws-sdk/xml-builder": "^3.972.5", - "@smithy/core": "^3.23.2", - "@smithy/node-config-provider": "^4.3.8", - "@smithy/property-provider": "^4.2.8", - "@smithy/protocol-http": "^5.3.8", - "@smithy/signature-v4": "^5.3.8", - "@smithy/smithy-client": "^4.11.5", - "@smithy/types": "^4.12.0", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-middleware": "^4.2.8", - "@smithy/util-utf8": "^4.2.0", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/xml-builder": "^3.972.22", + "@smithy/core": "^3.23.17", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/signature-v4": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.6", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -927,12 +931,12 @@ } }, "node_modules/@aws-sdk/crc64-nvme": { - "version": "3.972.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.0.tgz", - "integrity": "sha512-ThlLhTqX68jvoIVv+pryOdb5coP1cX1/MaTbB9xkGDCbWbsqQcLqzPxuSoW1DCnAAIacmXCWpzUNOB9pv+xXQw==", + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.7.tgz", + "integrity": "sha512-QUagVVBbC8gODCF6e1aV0mE2TXWB9Opz4k8EJFdNrujUVQm5R4AjJa1mpOqzwOuROBzqJU9zawzig7M96L8Ejg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.12.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -940,15 +944,15 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.9.tgz", - "integrity": "sha512-ZptrOwQynfupubvcngLkbdIq/aXvl/czdpEG8XJ8mN8Nb19BR0jaK0bR+tfuMU36Ez9q4xv7GGkHFqEEP2hUUQ==", + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.34.tgz", + "integrity": "sha512-XT0jtf8Fw9JE6ppsQeoNnZRiG+jqRixMT1v1ZR17G60UvVdsQmTG8nbEyHuEPfMxDXEhfdARaM/XiEhca4lGHQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.11", - "@aws-sdk/types": "^3.973.1", - "@smithy/property-provider": "^4.2.8", - "@smithy/types": "^4.12.0", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -956,20 +960,20 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.11.tgz", - "integrity": "sha512-hECWoOoH386bGr89NQc9vA/abkGf5TJrMREt+lhNcnSNmoBS04fK7vc3LrJBSQAUGGVj0Tz3f4dHB3w5veovig==", + "version": "3.972.36", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.36.tgz", + "integrity": "sha512-DPoGWfy7J7RKxvbf5kOKIGQkD2ek3dbKgzKIGrnLuvZBz5myU+Im/H6pmc14QcnFbqHMqxvtWSgRDSJW3qXLQg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.11", - "@aws-sdk/types": "^3.973.1", - "@smithy/fetch-http-handler": "^5.3.9", - "@smithy/node-http-handler": "^4.4.10", - "@smithy/property-provider": "^4.2.8", - "@smithy/protocol-http": "^5.3.8", - "@smithy/smithy-client": "^4.11.5", - "@smithy/types": "^4.12.0", - "@smithy/util-stream": "^4.5.12", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/types": "^3.973.8", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/property-provider": "^4.2.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/util-stream": "^4.5.25", "tslib": "^2.6.2" }, "engines": { @@ -977,24 +981,24 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.9.tgz", - "integrity": "sha512-zr1csEu9n4eDiHMTYJabX1mDGuGLgjgUnNckIivvk43DocJC9/f6DefFrnUPZXE+GHtbW50YuXb+JIxKykU74A==", + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.38.tgz", + "integrity": "sha512-oDzUBu2MGJFgoar05sPMCwSrhw44ASyccrHzj66vO69OZqi7I6hZZxXfuPLC8OCzW7C+sU+bI73XHij41yekgQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.11", - "@aws-sdk/credential-provider-env": "^3.972.9", - "@aws-sdk/credential-provider-http": "^3.972.11", - "@aws-sdk/credential-provider-login": "^3.972.9", - "@aws-sdk/credential-provider-process": "^3.972.9", - "@aws-sdk/credential-provider-sso": "^3.972.9", - "@aws-sdk/credential-provider-web-identity": "^3.972.9", - "@aws-sdk/nested-clients": "3.993.0", - "@aws-sdk/types": "^3.973.1", - "@smithy/credential-provider-imds": "^4.2.8", - "@smithy/property-provider": "^4.2.8", - "@smithy/shared-ini-file-loader": "^4.4.3", - "@smithy/types": "^4.12.0", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/credential-provider-env": "^3.972.34", + "@aws-sdk/credential-provider-http": "^3.972.36", + "@aws-sdk/credential-provider-login": "^3.972.38", + "@aws-sdk/credential-provider-process": "^3.972.34", + "@aws-sdk/credential-provider-sso": "^3.972.38", + "@aws-sdk/credential-provider-web-identity": "^3.972.38", + "@aws-sdk/nested-clients": "^3.997.6", + "@aws-sdk/types": "^3.973.8", + "@smithy/credential-provider-imds": "^4.2.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1002,18 +1006,18 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.9.tgz", - "integrity": "sha512-m4RIpVgZChv0vWS/HKChg1xLgZPpx8Z+ly9Fv7FwA8SOfuC6I3htcSaBz2Ch4bneRIiBUhwP4ziUo0UZgtJStQ==", + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.38.tgz", + "integrity": "sha512-g1NosS8qe4OF++G2UFCM5ovSkgipC7YYor5KCWatG0UoMSO5YFj9C8muePlyVmOBV/WTI16Jo3/s1NUo/o1Bww==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.11", - "@aws-sdk/nested-clients": "3.993.0", - "@aws-sdk/types": "^3.973.1", - "@smithy/property-provider": "^4.2.8", - "@smithy/protocol-http": "^5.3.8", - "@smithy/shared-ini-file-loader": "^4.4.3", - "@smithy/types": "^4.12.0", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/nested-clients": "^3.997.6", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1021,22 +1025,22 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.10.tgz", - "integrity": "sha512-70nCESlvnzjo4LjJ8By8MYIiBogkYPSXl3WmMZfH9RZcB/Nt9qVWbFpYj6Fk1vLa4Vk8qagFVeXgxdieMxG1QA==", + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.39.tgz", + "integrity": "sha512-HEswDQyxUtadoZ/bJsPPENHg7R0Lzym5LuMksJeHvqhCOpP+rtkDLKI4/ZChH4w3cf5kG8n6bZuI8PzajoiqMg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.9", - "@aws-sdk/credential-provider-http": "^3.972.11", - "@aws-sdk/credential-provider-ini": "^3.972.9", - "@aws-sdk/credential-provider-process": "^3.972.9", - "@aws-sdk/credential-provider-sso": "^3.972.9", - "@aws-sdk/credential-provider-web-identity": "^3.972.9", - "@aws-sdk/types": "^3.973.1", - "@smithy/credential-provider-imds": "^4.2.8", - "@smithy/property-provider": "^4.2.8", - "@smithy/shared-ini-file-loader": "^4.4.3", - "@smithy/types": "^4.12.0", + "@aws-sdk/credential-provider-env": "^3.972.34", + "@aws-sdk/credential-provider-http": "^3.972.36", + "@aws-sdk/credential-provider-ini": "^3.972.38", + "@aws-sdk/credential-provider-process": "^3.972.34", + "@aws-sdk/credential-provider-sso": "^3.972.38", + "@aws-sdk/credential-provider-web-identity": "^3.972.38", + "@aws-sdk/types": "^3.973.8", + "@smithy/credential-provider-imds": "^4.2.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1044,16 +1048,16 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.9.tgz", - "integrity": "sha512-gOWl0Fe2gETj5Bk151+LYKpeGi2lBDLNu+NMNpHRlIrKHdBmVun8/AalwMK8ci4uRfG5a3/+zvZBMpuen1SZ0A==", + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.34.tgz", + "integrity": "sha512-T3IFs4EVmVi1dVN5RciFnklCANSzvrQd/VuHY9ThHSQmYkTogjcGkoJEr+oNUPQZnso52183088NqysMPji1/Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.11", - "@aws-sdk/types": "^3.973.1", - "@smithy/property-provider": "^4.2.8", - "@smithy/shared-ini-file-loader": "^4.4.3", - "@smithy/types": "^4.12.0", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1061,18 +1065,18 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.9.tgz", - "integrity": "sha512-ey7S686foGTArvFhi3ifQXmgptKYvLSGE2250BAQceMSXZddz7sUSNERGJT2S7u5KIe/kgugxrt01hntXVln6w==", + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.38.tgz", + "integrity": "sha512-5ZxG+t0+3Q3QPh8KEjX6syskhgNf7I0MN7oGioTf6Lm1NTjfP7sIcYGNsthXC2qR8vcD3edNZwCr2ovfSSWuRA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/client-sso": "3.993.0", - "@aws-sdk/core": "^3.973.11", - "@aws-sdk/token-providers": "3.993.0", - "@aws-sdk/types": "^3.973.1", - "@smithy/property-provider": "^4.2.8", - "@smithy/shared-ini-file-loader": "^4.4.3", - "@smithy/types": "^4.12.0", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/nested-clients": "^3.997.6", + "@aws-sdk/token-providers": "3.1041.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1080,17 +1084,35 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.9.tgz", - "integrity": "sha512-8LnfS76nHXoEc9aRRiMMpxZxJeDG0yusdyo3NvPhCgESmBUgpMa4luhGbClW5NoX/qRcGxxM6Z/esqANSNMTow==", + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.38.tgz", + "integrity": "sha512-lYHFF30DGI20jZcYX8cm6Ns0V7f1dDN6g/MBDLTyD/5iw+bXs3yBr2iAiHDkx4RFU5JgsnZvCHYKiRVPRdmOgw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.11", - "@aws-sdk/nested-clients": "3.993.0", - "@aws-sdk/types": "^3.973.1", - "@smithy/property-provider": "^4.2.8", - "@smithy/shared-ini-file-loader": "^4.4.3", - "@smithy/types": "^4.12.0", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/nested-clients": "^3.997.6", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/ec2-metadata-service": { + "version": "3.1045.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/ec2-metadata-service/-/ec2-metadata-service-3.1045.0.tgz", + "integrity": "sha512-cYjEbjbGScw9l8TmI9AFYde1hIu5c9Wt0Qp7/cbWBHBiOzMfLwmjGhd5+4AUm1RsnmC5HZ/WOA9iGJHfHL4cuA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-stream": "^4.5.25", "tslib": "^2.6.2" }, "engines": { @@ -1098,17 +1120,17 @@ } }, "node_modules/@aws-sdk/middleware-bucket-endpoint": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.3.tgz", - "integrity": "sha512-fmbgWYirF67YF1GfD7cg5N6HHQ96EyRNx/rDIrTF277/zTWVuPI2qS/ZHgofwR1NZPe/NWvoppflQY01LrbVLg==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.10.tgz", + "integrity": "sha512-Vbc2frZH7wXlMNd+ZZSXUEs/l1Sv8Jj4zUnIfwrYF5lwaLdXHZ9xx4U3rjUcaye3HRhFVc+E5DbBxpRAbB16BA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.1", - "@aws-sdk/util-arn-parser": "^3.972.2", - "@smithy/node-config-provider": "^4.3.8", - "@smithy/protocol-http": "^5.3.8", - "@smithy/types": "^4.12.0", - "@smithy/util-config-provider": "^4.2.0", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-arn-parser": "^3.972.3", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -1116,14 +1138,14 @@ } }, "node_modules/@aws-sdk/middleware-expect-continue": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.3.tgz", - "integrity": "sha512-4msC33RZsXQpUKR5QR4HnvBSNCPLGHmB55oDiROqqgyOc+TOfVu2xgi5goA7ms6MdZLeEh2905UfWMnMMF4mRg==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.10.tgz", + "integrity": "sha512-2Yn0f1Qiq/DjxYR3wfI3LokXnjOhFM7Ssn4LTdFDIxRMCE6I32MAsVnhPX1cUZsuVA9tiZtwwhlSLAtFGxAZlQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.1", - "@smithy/protocol-http": "^5.3.8", - "@smithy/types": "^4.12.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1131,24 +1153,24 @@ } }, "node_modules/@aws-sdk/middleware-flexible-checksums": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.972.9.tgz", - "integrity": "sha512-E663+r/UQpvF3aJkD40p5ZANVQFsUcbE39jifMtN7wc0t1M0+2gJJp3i75R49aY9OiSX5lfVyPUNjN/BNRCCZA==", + "version": "3.974.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.16.tgz", + "integrity": "sha512-6ru8doI0/XzszqLIPXf0E/V7HhAw1Pu94010XCKYtBUfD0LxF0BuOzrUf8OQGR6j2o6wgKTHUniOmndQycHwCA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", - "@aws-sdk/core": "^3.973.11", - "@aws-sdk/crc64-nvme": "3.972.0", - "@aws-sdk/types": "^3.973.1", - "@smithy/is-array-buffer": "^4.2.0", - "@smithy/node-config-provider": "^4.3.8", - "@smithy/protocol-http": "^5.3.8", - "@smithy/types": "^4.12.0", - "@smithy/util-middleware": "^4.2.8", - "@smithy/util-stream": "^4.5.12", - "@smithy/util-utf8": "^4.2.0", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/crc64-nvme": "^3.972.7", + "@aws-sdk/types": "^3.973.8", + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-stream": "^4.5.25", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -1156,14 +1178,14 @@ } }, "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.3.tgz", - "integrity": "sha512-aknPTb2M+G3s+0qLCx4Li/qGZH8IIYjugHMv15JTYMe6mgZO8VBpYgeGYsNMGCqCZOcWzuf900jFBG5bopfzmA==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.10.tgz", + "integrity": "sha512-IJSsIMeVQ8MMCPbuh1AbltkFhLBLXn7aejzfX5YKT/VLDHn++Dcz8886tXckE+wQssyPUhaXrJhdakO2VilRhg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.1", - "@smithy/protocol-http": "^5.3.8", - "@smithy/types": "^4.12.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1171,13 +1193,13 @@ } }, "node_modules/@aws-sdk/middleware-location-constraint": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.3.tgz", - "integrity": "sha512-nIg64CVrsXp67vbK0U1/Is8rik3huS3QkRHn2DRDx4NldrEFMgdkZGI/+cZMKD9k4YOS110Dfu21KZLHrFA/1g==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.10.tgz", + "integrity": "sha512-rI3NZvJcEvjoD0+0PI0iUAwlPw2IlSlhyvgBK/3WkKJQE/YiKFedd9dMN2lVacdNxPNhxL/jzQaKQdrGtQagjQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.1", - "@smithy/types": "^4.12.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1185,13 +1207,13 @@ } }, "node_modules/@aws-sdk/middleware-logger": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.3.tgz", - "integrity": "sha512-Ftg09xNNRqaz9QNzlfdQWfpqMCJbsQdnZVJP55jfhbKi1+FTWxGuvfPoBhDHIovqWKjqbuiew3HuhxbJ0+OjgA==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.10.tgz", + "integrity": "sha512-OOuGvvz1Dm20SjZo5oEBePFqxt5nf8AwkNDSyUHvD9/bfNASmstcYxFAHUowy4n6Io7mWUZ04JURZwSBvyQanQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.1", - "@smithy/types": "^4.12.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1199,15 +1221,15 @@ } }, "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.3.tgz", - "integrity": "sha512-PY57QhzNuXHnwbJgbWYTrqIDHYSeOlhfYERTAuc16LKZpTZRJUjzBFokp9hF7u1fuGeE3D70ERXzdbMBOqQz7Q==", + "version": "3.972.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.11.tgz", + "integrity": "sha512-+zz6f79Kj9V5qFK2P+D8Ehjnw4AhphAlCAsPjUqEcInA9umtSSKMrHbSagEeOIsDNuvVrH98bjRHcyQukTrhaQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.1", + "@aws-sdk/types": "^3.973.8", "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/protocol-http": "^5.3.8", - "@smithy/types": "^4.12.0", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1215,24 +1237,24 @@ } }, "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.972.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.11.tgz", - "integrity": "sha512-Qr0T7ZQTRMOuR6ahxEoJR1thPVovfWrKB2a6KBGR+a8/ELrFodrgHwhq50n+5VMaGuLtGhHiISU3XGsZmtmVXQ==", + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.37.tgz", + "integrity": "sha512-Km7M+i8DrLArVzrid1gfxeGhYHBd3uxvE77g0s5a52zPSVosxzQBnJ0gwWb6NIp/DOk8gsBMhi7V+cpJG0ndTA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.11", - "@aws-sdk/types": "^3.973.1", - "@aws-sdk/util-arn-parser": "^3.972.2", - "@smithy/core": "^3.23.2", - "@smithy/node-config-provider": "^4.3.8", - "@smithy/protocol-http": "^5.3.8", - "@smithy/signature-v4": "^5.3.8", - "@smithy/smithy-client": "^4.11.5", - "@smithy/types": "^4.12.0", - "@smithy/util-config-provider": "^4.2.0", - "@smithy/util-middleware": "^4.2.8", - "@smithy/util-stream": "^4.5.12", - "@smithy/util-utf8": "^4.2.0", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-arn-parser": "^3.972.3", + "@smithy/core": "^3.23.17", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/signature-v4": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-stream": "^4.5.25", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -1240,13 +1262,13 @@ } }, "node_modules/@aws-sdk/middleware-ssec": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.3.tgz", - "integrity": "sha512-dU6kDuULN3o3jEHcjm0c4zWJlY1zWVkjG9NPe9qxYLLpcbdj5kRYBS2DdWYD+1B9f910DezRuws7xDEqKkHQIg==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.10.tgz", + "integrity": "sha512-Gli9A0u8EVVb+5bFDGS/QbSVg28w/wpEidg1ggVcSj65BDTdGR6punsOcVjqdiu1i42WHWo51MCvARPIIz9juw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.1", - "@smithy/types": "^4.12.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1254,17 +1276,18 @@ } }, "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.972.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.11.tgz", - "integrity": "sha512-R8CvPsPHXwzIHCAza+bllY6PrctEk4lYq/SkHJz9NLoBHCcKQrbOcsfXxO6xmipSbUNIbNIUhH0lBsJGgsRdiw==", + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.38.tgz", + "integrity": "sha512-iz+B29TXcAZsJpwB+AwG/TTGA5l/VnmMZ2UxtiySOZjI6gCdmviXPwdgzcmuazMy16rXoPY4mYCGe7zdNKfx5A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.11", - "@aws-sdk/types": "^3.973.1", - "@aws-sdk/util-endpoints": "3.993.0", - "@smithy/core": "^3.23.2", - "@smithy/protocol-http": "^5.3.8", - "@smithy/types": "^4.12.0", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-endpoints": "^3.996.8", + "@smithy/core": "^3.23.17", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-retry": "^4.3.6", "tslib": "^2.6.2" }, "engines": { @@ -1272,15 +1295,15 @@ } }, "node_modules/@aws-sdk/middleware-user-agent/node_modules/@aws-sdk/util-endpoints": { - "version": "3.993.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.993.0.tgz", - "integrity": "sha512-j6vioBeRZ4eHX4SWGvGPpwGg/xSOcK7f1GL0VM+rdf3ZFTIsUEhCFmD78B+5r2PgztcECSzEfvHQX01k8dPQPw==", + "version": "3.996.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.8.tgz", + "integrity": "sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.1", - "@smithy/types": "^4.12.0", - "@smithy/url-parser": "^4.2.8", - "@smithy/util-endpoints": "^3.2.8", + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-endpoints": "^3.4.2", "tslib": "^2.6.2" }, "engines": { @@ -1288,48 +1311,66 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.993.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.993.0.tgz", - "integrity": "sha512-iOq86f2H67924kQUIPOAvlmMaOAvOLoDOIb66I2YqSUpMYB6ufiuJW3RlREgskxv86S5qKzMnfy/X6CqMjK6XQ==", + "version": "3.997.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.6.tgz", + "integrity": "sha512-WBDnqatJl+kGObpfmfSxqnXeYTu3Me8wx8WCtvoxX3pfWrrTv8I4WTMSSs7PZqcRcVh8WeUKMgGFjMG+52SR1w==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.11", - "@aws-sdk/middleware-host-header": "^3.972.3", - "@aws-sdk/middleware-logger": "^3.972.3", - "@aws-sdk/middleware-recursion-detection": "^3.972.3", - "@aws-sdk/middleware-user-agent": "^3.972.11", - "@aws-sdk/region-config-resolver": "^3.972.3", - "@aws-sdk/types": "^3.973.1", - "@aws-sdk/util-endpoints": "3.993.0", - "@aws-sdk/util-user-agent-browser": "^3.972.3", - "@aws-sdk/util-user-agent-node": "^3.972.9", - "@smithy/config-resolver": "^4.4.6", - "@smithy/core": "^3.23.2", - "@smithy/fetch-http-handler": "^5.3.9", - "@smithy/hash-node": "^4.2.8", - "@smithy/invalid-dependency": "^4.2.8", - "@smithy/middleware-content-length": "^4.2.8", - "@smithy/middleware-endpoint": "^4.4.16", - "@smithy/middleware-retry": "^4.4.33", - "@smithy/middleware-serde": "^4.2.9", - "@smithy/middleware-stack": "^4.2.8", - "@smithy/node-config-provider": "^4.3.8", - "@smithy/node-http-handler": "^4.4.10", - "@smithy/protocol-http": "^5.3.8", - "@smithy/smithy-client": "^4.11.5", - "@smithy/types": "^4.12.0", - "@smithy/url-parser": "^4.2.8", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-body-length-node": "^4.2.1", - "@smithy/util-defaults-mode-browser": "^4.3.32", - "@smithy/util-defaults-mode-node": "^4.2.35", - "@smithy/util-endpoints": "^3.2.8", - "@smithy/util-middleware": "^4.2.8", - "@smithy/util-retry": "^4.2.8", - "@smithy/util-utf8": "^4.2.0", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/middleware-host-header": "^3.972.10", + "@aws-sdk/middleware-logger": "^3.972.10", + "@aws-sdk/middleware-recursion-detection": "^3.972.11", + "@aws-sdk/middleware-user-agent": "^3.972.38", + "@aws-sdk/region-config-resolver": "^3.972.13", + "@aws-sdk/signature-v4-multi-region": "^3.996.25", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-endpoints": "^3.996.8", + "@aws-sdk/util-user-agent-browser": "^3.972.10", + "@aws-sdk/util-user-agent-node": "^3.973.24", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/hash-node": "^4.2.14", + "@smithy/invalid-dependency": "^4.2.14", + "@smithy/middleware-content-length": "^4.2.14", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.7", + "@smithy/middleware-serde": "^4.2.20", + "@smithy/middleware-stack": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.6", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.25", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.25.tgz", + "integrity": "sha512-+CMIt3e1VzlklAECmG+DtP1sV8iKq25FuA0OKpnJ4KA0kxUtd7CgClY7/RU6VzJBQwbN4EJ9Ue6plvqx1qGadw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "^3.972.37", + "@aws-sdk/types": "^3.973.8", + "@smithy/protocol-http": "^5.3.14", + "@smithy/signature-v4": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1337,15 +1378,15 @@ } }, "node_modules/@aws-sdk/nested-clients/node_modules/@aws-sdk/util-endpoints": { - "version": "3.993.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.993.0.tgz", - "integrity": "sha512-j6vioBeRZ4eHX4SWGvGPpwGg/xSOcK7f1GL0VM+rdf3ZFTIsUEhCFmD78B+5r2PgztcECSzEfvHQX01k8dPQPw==", + "version": "3.996.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.8.tgz", + "integrity": "sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.1", - "@smithy/types": "^4.12.0", - "@smithy/url-parser": "^4.2.8", - "@smithy/util-endpoints": "^3.2.8", + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-endpoints": "^3.4.2", "tslib": "^2.6.2" }, "engines": { @@ -1353,15 +1394,15 @@ } }, "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.3.tgz", - "integrity": "sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow==", + "version": "3.972.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.13.tgz", + "integrity": "sha512-CvJ2ZIjK/jVD/lbOpowBVElJyC1YxLTIJ13yM0AEo0t2v7swOzGjSA6lJGH+DwZXQhcjUjoYwc8bVYCX5MDr1A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.1", - "@smithy/config-resolver": "^4.4.6", - "@smithy/node-config-provider": "^4.3.8", - "@smithy/types": "^4.12.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/config-resolver": "^4.4.17", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1405,17 +1446,17 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.993.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.993.0.tgz", - "integrity": "sha512-+35g4c+8r7sB9Sjp1KPdM8qxGn6B/shBjJtEUN4e+Edw9UEQlZKIzioOGu3UAbyE0a/s450LdLZr4wbJChtmww==", + "version": "3.1041.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1041.0.tgz", + "integrity": "sha512-Th7kPI6YPtvJUcdznooXJMy+9rQWjmEF81LxaJssngBzuysK4a/x+l8kjm1zb7nYsUPbndnBdUnwng/3PLvtGw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.11", - "@aws-sdk/nested-clients": "3.993.0", - "@aws-sdk/types": "^3.973.1", - "@smithy/property-provider": "^4.2.8", - "@smithy/shared-ini-file-loader": "^4.4.3", - "@smithy/types": "^4.12.0", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/nested-clients": "^3.997.6", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1423,12 +1464,12 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.973.1", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.1.tgz", - "integrity": "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg==", + "version": "3.973.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", + "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.12.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1436,9 +1477,9 @@ } }, "node_modules/@aws-sdk/util-arn-parser": { - "version": "3.972.2", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.2.tgz", - "integrity": "sha512-VkykWbqMjlSgBFDyrY3nOSqupMc6ivXuGmvci6Q3NnLq5kC+mKQe2QBZ4nrWRE/jqOxeFP2uYzLtwncYYcvQDg==", + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.3.tgz", + "integrity": "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -1490,27 +1531,28 @@ } }, "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.3.tgz", - "integrity": "sha512-JurOwkRUcXD/5MTDBcqdyQ9eVedtAsZgw5rBwktsPTN7QtPiS2Ld1jkJepNgYoCufz1Wcut9iup7GJDoIHp8Fw==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.10.tgz", + "integrity": "sha512-FAzqXvfEssGdSIz8ejatan0bOdx1qefBWKF/gWmVBXIP1HkS7v/wjjaqrAGGKvyihrXTXW00/2/1nTJtxpXz7g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.1", - "@smithy/types": "^4.12.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.972.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.972.10.tgz", - "integrity": "sha512-LVXzICPlsheET+sE6tkcS47Q5HkSTrANIlqL1iFxGAY/wRQ236DX/PCAK56qMh9QJoXAfXfoRW0B0Og4R+X7Nw==", + "version": "3.973.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.24.tgz", + "integrity": "sha512-ZWwlkjcIp7cEL8ZfTpTAPNkwx25p7xol0xlKoWVVf22+nsjwmLcHYtTPjIV1cSpmB/b6DaK4cb1fSkvCXHgRdw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-user-agent": "^3.972.11", - "@aws-sdk/types": "^3.973.1", - "@smithy/node-config-provider": "^4.3.8", - "@smithy/types": "^4.12.0", + "@aws-sdk/middleware-user-agent": "^3.972.38", + "@aws-sdk/types": "^3.973.8", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -1526,13 +1568,14 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.5.tgz", - "integrity": "sha512-mCae5Ys6Qm1LDu0qdGwx2UQ63ONUe+FHw908fJzLDqFKTDBK4LDZUqKWm4OkTCNFq19bftjsBSESIGLD/s3/rA==", + "version": "3.972.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.22.tgz", + "integrity": "sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.12.0", - "fast-xml-parser": "5.3.6", + "@nodable/entities": "2.1.0", + "@smithy/types": "^4.14.1", + "fast-xml-parser": "5.7.2", "tslib": "^2.6.2" }, "engines": { @@ -1548,6 +1591,279 @@ "node": ">=18.0.0" } }, + "node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz", + "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-client": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.1.tgz", + "integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-http-compat": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@azure/core-http-compat/-/core-http-compat-2.4.0.tgz", + "integrity": "sha512-f1P96IB399YiN2ARYHP7EpZi3Bf3wH4SN2lGzrw7JVwm7bbsVYtf2iKSBwTywD2P62NOPZGHFSZi+6jjb75JuA==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@azure/core-client": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0" + } + }, + "node_modules/@azure/core-lro": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.7.2.tgz", + "integrity": "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-util": "^1.2.0", + "@azure/logger": "^1.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-paging": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.6.2.tgz", + "integrity": "sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-rest-pipeline": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.23.0.tgz", + "integrity": "sha512-Evs1INHo+jUjwHi1T6SG6Ua/LHOQBCLuKEEE6efIpt4ZOoNonaT1kP32GoOcdNDbfqsD2445CPri3MubBy5DEQ==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-tracing": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz", + "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz", + "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-xml": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@azure/core-xml/-/core-xml-1.5.1.tgz", + "integrity": "sha512-xcNRHqCoSp4AunOALEae6A8f3qATb83gSrm31Iqb01OzblvC3/W/bfXozcq78EzIdzZzuH1bZ2NvRR0TdX709w==", + "license": "MIT", + "dependencies": { + "fast-xml-parser": "^5.5.9", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-xml/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@azure/identity": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.1.tgz", + "integrity": "sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.2", + "@azure/core-rest-pipeline": "^1.17.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.0.0", + "@azure/msal-browser": "^5.5.0", + "@azure/msal-node": "^5.1.0", + "open": "^10.1.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/identity/node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@azure/identity/node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@azure/logger": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz", + "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==", + "license": "MIT", + "dependencies": { + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/msal-browser": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.10.1.tgz", + "integrity": "sha512-hTbvOi9Ko2Jvn+G/fSmjzHf9WbNcf/o3epMtbeGx/pMwMrVAbi6OgCJVeCfsAb8IybSRpaCSc4EDRlYAhgngUQ==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.6.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.6.1.tgz", + "integrity": "sha512-VxKdEtUwDuLD0F1hOQP7kye0YadZxFJfv37Em440geEf/w9uggKnHpRrqwZJOdxmPUOdhZ9kyRtKuAJW8wUcRg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.2.1.tgz", + "integrity": "sha512-tmQiQ2HvtzaeLqYGy3BemiPOSGPY4wCy1IW5zDWITKSs/s35WEd7Zij/hCxvUdAOzj6U3qnyaGbYXY91ortFEQ==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.6.1", + "jsonwebtoken": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@azure/storage-blob": { + "version": "12.26.0", + "resolved": "https://registry.npmjs.org/@azure/storage-blob/-/storage-blob-12.26.0.tgz", + "integrity": "sha512-SriLPKezypIsiZ+TtlFfE46uuBIap2HeaQVS78e1P7rz5OSbq0rsd52WE1mC5f7vAeLiXqv7I7oRhL3WFZEw3Q==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.4.0", + "@azure/core-client": "^1.6.2", + "@azure/core-http-compat": "^2.0.0", + "@azure/core-lro": "^2.2.0", + "@azure/core-paging": "^1.1.1", + "@azure/core-rest-pipeline": "^1.10.1", + "@azure/core-tracing": "^1.1.2", + "@azure/core-util": "^1.6.1", + "@azure/core-xml": "^1.4.3", + "@azure/logger": "^1.0.0", + "events": "^3.0.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.24.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz", @@ -4331,6 +4647,17 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", + "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", + "license": "MIT", + "dependencies": { + "@so-ric/colorspace": "^1.1.6", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, "node_modules/@discoveryjs/json-ext": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", @@ -6082,6 +6409,18 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -10500,55 +10839,13 @@ "@sinonjs/commons": "^3.0.0" } }, - "node_modules/@smithy/abort-controller": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.8.tgz", - "integrity": "sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.12.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/chunked-blob-reader": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.0.tgz", - "integrity": "sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/chunked-blob-reader-native": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.1.tgz", - "integrity": "sha512-lX9Ay+6LisTfpLid2zZtIhSEjHMZoAR5hHCR4H7tBz/Zkfr5ea8RcQ7Tk4mi0P76p4cN+Btz16Ffno7YHpKXnQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-base64": "^4.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@smithy/config-resolver": { - "version": "4.4.6", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.6.tgz", - "integrity": "sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==", + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.5.1.tgz", + "integrity": "sha512-abXk3LhODsvRHsk0ZS9ztrg/fZatTa9Z/z4pgx65YSLR+rY6kvUG/1IgcDKEUciR8MfdnkT5oPeHJTy/HhzDIQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.8", - "@smithy/types": "^4.12.0", - "@smithy/util-config-provider": "^4.2.0", - "@smithy/util-endpoints": "^3.2.8", - "@smithy/util-middleware": "^4.2.8", + "@smithy/core": "^3.24.1", "tslib": "^2.6.2" }, "engines": { @@ -10556,20 +10853,13 @@ } }, "node_modules/@smithy/core": { - "version": "3.23.2", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.2.tgz", - "integrity": "sha512-HaaH4VbGie4t0+9nY3tNBRSxVTr96wzIqexUa6C2qx3MPePAuz7lIxPxYtt1Wc//SPfJLNoZJzfdt0B6ksj2jA==", + "version": "3.24.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.1.tgz", + "integrity": "sha512-3mT7o4qQyUWttYnVK3A0Z/u3Xha3E81tXn32Tz6vjZiUXhBrkEivpw1hBYfh84iFF9CSzkBU9Y1DJ3Q6RQ231g==", "license": "Apache-2.0", "dependencies": { - "@smithy/middleware-serde": "^4.2.9", - "@smithy/protocol-http": "^5.3.8", - "@smithy/types": "^4.12.0", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-middleware": "^4.2.8", - "@smithy/util-stream": "^4.5.12", - "@smithy/util-utf8": "^4.2.0", - "@smithy/uuid": "^1.1.0", + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -10577,30 +10867,13 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.8.tgz", - "integrity": "sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.8", - "@smithy/property-provider": "^4.2.8", - "@smithy/types": "^4.12.0", - "@smithy/url-parser": "^4.2.8", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-codec": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.8.tgz", - "integrity": "sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.1.tgz", + "integrity": "sha512-0S/acwHnqX4WrjXzhdiDRxsG2s9SC0cpPIK9nZ1R6UOHd+j7uL28+4bHu22urbLk2TVw3fkp6na/+fkUt/pLNQ==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.12.0", - "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/core": "^3.24.1", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -10608,13 +10881,12 @@ } }, "node_modules/@smithy/eventstream-serde-browser": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.8.tgz", - "integrity": "sha512-MTfQT/CRQz5g24ayXdjg53V0mhucZth4PESoA5IhvaWVDTOQLfo8qI9vzqHcPsdd2v6sqfTYqF5L/l+pea5Uyw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.3.1.tgz", + "integrity": "sha512-X7MyI1fu8M84IPKk49kO4kb27Mqp6un9/0o/MsA1ngZ5OxxWKGUxPS3S/AJ9q1cPVTSGmRcbaGNfGUSsflTJkg==", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.8", - "@smithy/types": "^4.12.0", + "@smithy/core": "^3.24.1", "tslib": "^2.6.2" }, "engines": { @@ -10622,12 +10894,12 @@ } }, "node_modules/@smithy/eventstream-serde-config-resolver": { - "version": "4.3.8", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.8.tgz", - "integrity": "sha512-ah12+luBiDGzBruhu3efNy1IlbwSEdNiw8fOZksoKoWW1ZHvO/04MQsdnws/9Aj+5b0YXSSN2JXKy/ClIsW8MQ==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.4.1.tgz", + "integrity": "sha512-JZGbSXaBk7JY8VPzsh66ksJ0nTWXbApduFDkA/pEl3aTm2EoAiUZE1Iltp6c+X1bB8kxPQW0mHDfVdYCpWTOzg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.12.0", + "@smithy/core": "^3.24.1", "tslib": "^2.6.2" }, "engines": { @@ -10635,27 +10907,12 @@ } }, "node_modules/@smithy/eventstream-serde-node": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.8.tgz", - "integrity": "sha512-cYpCpp29z6EJHa5T9WL0KAlq3SOKUQkcgSoeRfRVwjGgSFl7Uh32eYGt7IDYCX20skiEdRffyDpvF2efEZPC0A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.8", - "@smithy/types": "^4.12.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-universal": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.8.tgz", - "integrity": "sha512-iJ6YNJd0bntJYnX6s52NC4WFYcZeKrPUr1Kmmr5AwZcwCSzVpS7oavAmxMR7pMq7V+D1G4s9F5NJK0xwOsKAlQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.3.1.tgz", + "integrity": "sha512-6Cn4xTNVxn9PWTHSbvf8zmcDhQW8lrLE1Xq5CJgmX6wEvdjS2S0KuE79Aiznv/jx51jpFJ98OuWyE+Bt+oG1MQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-codec": "^4.2.8", - "@smithy/types": "^4.12.0", + "@smithy/core": "^3.24.1", "tslib": "^2.6.2" }, "engines": { @@ -10663,15 +10920,13 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.9", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.9.tgz", - "integrity": "sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==", + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.1.tgz", + "integrity": "sha512-r7bN6spQ+caZC8AnyvSxkRUb57zt2jhhRw3Z+2Ez8hjq6coIikDBFUUI/+CQ1xx9K6eX1Gx6wUKo4ylU66TIqw==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.8", - "@smithy/querystring-builder": "^4.2.8", - "@smithy/types": "^4.12.0", - "@smithy/util-base64": "^4.3.0", + "@smithy/core": "^3.24.1", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -10679,14 +10934,12 @@ } }, "node_modules/@smithy/hash-blob-browser": { - "version": "4.2.9", - "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.9.tgz", - "integrity": "sha512-m80d/iicI7DlBDxyQP6Th7BW/ejDGiF0bgI754+tiwK0lgMkcaIBgvwwVc7OFbY4eUzpGtnig52MhPAEJ7iNYg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.3.1.tgz", + "integrity": "sha512-2fbltQVQYmGd0OzPv2oDMRF0pxkzeIx8cbpx2x6W3UJWGaEyUzVPxF4d0sDXZ/r2obg+RbTyhTidXWlPDsKRKw==", "license": "Apache-2.0", "dependencies": { - "@smithy/chunked-blob-reader": "^5.2.0", - "@smithy/chunked-blob-reader-native": "^4.2.1", - "@smithy/types": "^4.12.0", + "@smithy/core": "^3.24.1", "tslib": "^2.6.2" }, "engines": { @@ -10694,14 +10947,12 @@ } }, "node_modules/@smithy/hash-node": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.8.tgz", - "integrity": "sha512-7ZIlPbmaDGxVoxErDZnuFG18WekhbA/g2/i97wGj+wUBeS6pcUeAym8u4BXh/75RXWhgIJhyC11hBzig6MljwA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.3.1.tgz", + "integrity": "sha512-u0/zo11mg7yNneoYgTkH4sXwSmcBpbl49o4UNCtQ7hYsXxynsN25KYHmXzqi7TPk5HQL5klGnpU5koOY0O+9hw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.12.0", - "@smithy/util-buffer-from": "^4.2.0", - "@smithy/util-utf8": "^4.2.0", + "@smithy/core": "^3.24.1", "tslib": "^2.6.2" }, "engines": { @@ -10709,13 +10960,12 @@ } }, "node_modules/@smithy/hash-stream-node": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.8.tgz", - "integrity": "sha512-v0FLTXgHrTeheYZFGhR+ehX5qUm4IQsjAiL9qehad2cyjMWcN2QG6/4mSwbSgEQzI7jwfoXj7z4fxZUx/Mhj2w==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.3.1.tgz", + "integrity": "sha512-4NOnngIoXngbJw9By3u8KXRgqt4vYATpAobNBnNWxOREP7JY3kB0bUmbBNhZ7dtZV/b4auO1eFMD4cLj9OauVg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.12.0", - "@smithy/util-utf8": "^4.2.0", + "@smithy/core": "^3.24.1", "tslib": "^2.6.2" }, "engines": { @@ -10723,12 +10973,12 @@ } }, "node_modules/@smithy/invalid-dependency": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.8.tgz", - "integrity": "sha512-N9iozRybwAQ2dn9Fot9kI6/w9vos2oTXLhtK7ovGqwZjlOcxu6XhPlpLpC+INsxktqHinn5gS2DXDjDF2kG5sQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.3.1.tgz", + "integrity": "sha512-cLmwtDoulyZvRepAfyV+3rx5oMvuh51dbE+6En3vGC09j3uVSRt1U4oguNu32ub3soGX0oYtBs8E7S2Q4SxTqg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.12.0", + "@smithy/core": "^3.24.1", "tslib": "^2.6.2" }, "engines": { @@ -10736,11 +10986,12 @@ } }, "node_modules/@smithy/is-array-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.0.tgz", - "integrity": "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.3.1.tgz", + "integrity": "sha512-9aVG6VjOFVFHC6Z4hGAzIIrsVWpp1QOO4ERQ2k1S19VrgCamUGIBE2ilAnMWCfr+mlowHlLRXBStsTk/2c5HfA==", "license": "Apache-2.0", "dependencies": { + "@smithy/core": "^3.24.1", "tslib": "^2.6.2" }, "engines": { @@ -10748,13 +10999,12 @@ } }, "node_modules/@smithy/md5-js": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.8.tgz", - "integrity": "sha512-oGMaLj4tVZzLi3itBa9TCswgMBr7k9b+qKYowQ6x1rTyTuO1IU2YHdHUa+891OsOH+wCsH7aTPRsTJO3RMQmjQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.3.1.tgz", + "integrity": "sha512-98NalujRdzv6ggVQNYPWpL2K57UKeUB8roIr61u6+JiHd7KUlMQ+sn/vk6IG4XxEjw2vlC7eu/xjYXshUE4XXg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.12.0", - "@smithy/util-utf8": "^4.2.0", + "@smithy/core": "^3.24.1", "tslib": "^2.6.2" }, "engines": { @@ -10762,13 +11012,12 @@ } }, "node_modules/@smithy/middleware-content-length": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.8.tgz", - "integrity": "sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.3.1.tgz", + "integrity": "sha512-l4BUIP+wljW/Ar+0/QcGdmElI9lalrywfzNijXMBG34Z510FRzPyrDLx/blNTZOAm0C4Mvx5t/bf760CZo1ajg==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.8", - "@smithy/types": "^4.12.0", + "@smithy/core": "^3.24.1", "tslib": "^2.6.2" }, "engines": { @@ -10776,18 +11025,12 @@ } }, "node_modules/@smithy/middleware-endpoint": { - "version": "4.4.16", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.16.tgz", - "integrity": "sha512-L5GICFCSsNhbJ5JSKeWFGFy16Q2OhoBizb3X2DrxaJwXSEujVvjG9Jt386dpQn2t7jINglQl0b4K/Su69BdbMA==", + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.5.1.tgz", + "integrity": "sha512-qtqu5TS+8Y18ZDkJoiXN5AMW1G4JAg1+xytzpsUvIR5a4EUsgd5HQg12lekEHWpm2TDUmOgg+hBaHK7dvyWdkA==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.2", - "@smithy/middleware-serde": "^4.2.9", - "@smithy/node-config-provider": "^4.3.8", - "@smithy/shared-ini-file-loader": "^4.4.3", - "@smithy/types": "^4.12.0", - "@smithy/url-parser": "^4.2.8", - "@smithy/util-middleware": "^4.2.8", + "@smithy/core": "^3.24.1", "tslib": "^2.6.2" }, "engines": { @@ -10795,19 +11038,12 @@ } }, "node_modules/@smithy/middleware-retry": { - "version": "4.4.33", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.33.tgz", - "integrity": "sha512-jLqZOdJhtIL4lnA9hXnAG6GgnJlo1sD3FqsTxm9wSfjviqgWesY/TMBVnT84yr4O0Vfe0jWoXlfFbzsBVph3WA==", + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.6.1.tgz", + "integrity": "sha512-eTaQhxs0rfUuAkL2MSKrH8DTO7YCeAgrdN0B2/RAeuHmXQ+x52dk5qUBsi/jtcqe5LxItgq5AG5tI6Cp8c0sow==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.8", - "@smithy/protocol-http": "^5.3.8", - "@smithy/service-error-classification": "^4.2.8", - "@smithy/smithy-client": "^4.11.5", - "@smithy/types": "^4.12.0", - "@smithy/util-middleware": "^4.2.8", - "@smithy/util-retry": "^4.2.8", - "@smithy/uuid": "^1.1.0", + "@smithy/core": "^3.24.1", "tslib": "^2.6.2" }, "engines": { @@ -10815,13 +11051,12 @@ } }, "node_modules/@smithy/middleware-serde": { - "version": "4.2.9", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.9.tgz", - "integrity": "sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.3.1.tgz", + "integrity": "sha512-t7YtUe076zWVypVmy1rX91oKi2TFJCkpfFpfMhJFpEIRPP0iL9JxjeSyFQ+1bF45JUfDzOzslUJa150WcSrBug==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.8", - "@smithy/types": "^4.12.0", + "@smithy/core": "^3.24.1", "tslib": "^2.6.2" }, "engines": { @@ -10829,12 +11064,12 @@ } }, "node_modules/@smithy/middleware-stack": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.8.tgz", - "integrity": "sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.3.1.tgz", + "integrity": "sha512-1jKwiKZxCMQNqmp4uVPYA6r+MLGjEtH07gnOUdPgbnjuOIrl/0JY/ICdpQtFgeBsQ/Up01gnSv8GYEL0fb8yvg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.12.0", + "@smithy/core": "^3.24.1", "tslib": "^2.6.2" }, "engines": { @@ -10842,14 +11077,12 @@ } }, "node_modules/@smithy/node-config-provider": { - "version": "4.3.8", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.8.tgz", - "integrity": "sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.4.1.tgz", + "integrity": "sha512-q7tDJEJXcaSG/8TVpu2f2l9bzxTzDM9geWmltbzsY6Hfh3yiuXXTpLIO8+zwYASPPVFaTJpdKwjSSjdoDoccgw==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.8", - "@smithy/shared-ini-file-loader": "^4.4.3", - "@smithy/types": "^4.12.0", + "@smithy/core": "^3.24.1", "tslib": "^2.6.2" }, "engines": { @@ -10857,15 +11090,13 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.4.10", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.10.tgz", - "integrity": "sha512-u4YeUwOWRZaHbWaebvrs3UhwQwj+2VNmcVCwXcYTvPIuVyM7Ex1ftAj+fdbG/P4AkBwLq/+SKn+ydOI4ZJE9PA==", + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.1.tgz", + "integrity": "sha512-BdEYko85f/ldp68uH8XEyIvo810xFk6eyPH81SRggTOApYHWA+Xu7B2EzLuHbe37WVLaUA7F1fWR3/zBeme2WA==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.2.8", - "@smithy/protocol-http": "^5.3.8", - "@smithy/querystring-builder": "^4.2.8", - "@smithy/types": "^4.12.0", + "@smithy/core": "^3.24.1", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -10873,12 +11104,12 @@ } }, "node_modules/@smithy/property-provider": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.8.tgz", - "integrity": "sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.3.1.tgz", + "integrity": "sha512-3NHoqVBhzpY2b4YBx9AqyKC4C8nnEjl5FyKuxrCjvnjinG0ODj+yg1xX360nNahT6wghYjSw1SooCt3kIdnqIA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.12.0", + "@smithy/core": "^3.24.1", "tslib": "^2.6.2" }, "engines": { @@ -10886,12 +11117,12 @@ } }, "node_modules/@smithy/protocol-http": { - "version": "5.3.8", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.8.tgz", - "integrity": "sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ==", + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.4.1.tgz", + "integrity": "sha512-8irPNCQgYxcSFp1aGcnDNFkTwSA+xPUaFq9V/v1+JXWu8sKr5b3cFmg2kBTkjkvypDmGeNffuNu0x5iqw1NoAw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.12.0", + "@smithy/core": "^3.24.1", "tslib": "^2.6.2" }, "engines": { @@ -10912,38 +11143,13 @@ "node": ">=18.0.0" } }, - "node_modules/@smithy/querystring-parser": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.8.tgz", - "integrity": "sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.12.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/service-error-classification": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.8.tgz", - "integrity": "sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.12.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.3.tgz", - "integrity": "sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg==", + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.5.1.tgz", + "integrity": "sha512-FKoKxVzdFPhyynFI+SPTWrgOP60fZ4l1UwukWYj4eyhpSmEI7MJ6p58hawIIt9bwp+aek9NEm8Zika7E+GEoeg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.12.0", + "@smithy/core": "^3.24.1", "tslib": "^2.6.2" }, "engines": { @@ -10951,18 +11157,13 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.3.8", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.8.tgz", - "integrity": "sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==", + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.1.tgz", + "integrity": "sha512-728lZZEWYWubBESrfntNslZQYDKRlJDY4dcDnYbL50+gu35pGPLblu4S0/RH/RDLF6me1M87ECHsHELGL7dA/Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.2.0", - "@smithy/protocol-http": "^5.3.8", - "@smithy/types": "^4.12.0", - "@smithy/util-hex-encoding": "^4.2.0", - "@smithy/util-middleware": "^4.2.8", - "@smithy/util-uri-escape": "^4.2.0", - "@smithy/util-utf8": "^4.2.0", + "@smithy/core": "^3.24.1", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -10970,17 +11171,13 @@ } }, "node_modules/@smithy/smithy-client": { - "version": "4.11.5", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.11.5.tgz", - "integrity": "sha512-xixwBRqoeP2IUgcAl3U9dvJXc+qJum4lzo3maaJxifsZxKUYLfVfCXvhT4/jD01sRrHg5zjd1cw2Zmjr4/SuKQ==", + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.13.1.tgz", + "integrity": "sha512-IcznNM8Qd9u1X3oflp12tkzyOB4HbT+sfYWlWiyEysgNzSHoWcHUUsTT4y1jjDjtVuuVVQbYks+g1kVd7u1eGQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.2", - "@smithy/middleware-endpoint": "^4.4.16", - "@smithy/middleware-stack": "^4.2.8", - "@smithy/protocol-http": "^5.3.8", - "@smithy/types": "^4.12.0", - "@smithy/util-stream": "^4.5.12", + "@smithy/core": "^3.24.1", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -10988,9 +11185,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.12.0.tgz", - "integrity": "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==", + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.1.tgz", + "integrity": "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -11000,13 +11197,12 @@ } }, "node_modules/@smithy/url-parser": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.8.tgz", - "integrity": "sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.3.1.tgz", + "integrity": "sha512-tuelFlF2PZR/wogFC58NIrPOv+Zna4N1+3kA161/33D1Gbwvl6Nh4WsAsW05ZyPp0O6CMGsdbb0S2b/qVjRMCw==", "license": "Apache-2.0", "dependencies": { - "@smithy/querystring-parser": "^4.2.8", - "@smithy/types": "^4.12.0", + "@smithy/core": "^3.24.1", "tslib": "^2.6.2" }, "engines": { @@ -11014,13 +11210,12 @@ } }, "node_modules/@smithy/util-base64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.0.tgz", - "integrity": "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.4.1.tgz", + "integrity": "sha512-fTHiwW2xbiRiWzfSk4IGAr3gNZCH4fuRYqt8+IuarsP/YON35576iVdePraZ6yJlFxlCL0eMec3/F7xYqoKzlg==", "license": "Apache-2.0", "dependencies": { - "@smithy/util-buffer-from": "^4.2.0", - "@smithy/util-utf8": "^4.2.0", + "@smithy/core": "^3.24.1", "tslib": "^2.6.2" }, "engines": { @@ -11028,11 +11223,12 @@ } }, "node_modules/@smithy/util-body-length-browser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.0.tgz", - "integrity": "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.3.1.tgz", + "integrity": "sha512-1scg5t4nV3hV7CZs996/XHb80aDZ5YotH4NcvkW/w/rHj+cSz0aCIzwz8aUNKB4nCDPSHRCbrKoj+TvycYefmw==", "license": "Apache-2.0", "dependencies": { + "@smithy/core": "^3.24.1", "tslib": "^2.6.2" }, "engines": { @@ -11040,24 +11236,12 @@ } }, "node_modules/@smithy/util-body-length-node": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.1.tgz", - "integrity": "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-buffer-from": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.0.tgz", - "integrity": "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.3.1.tgz", + "integrity": "sha512-VRC8MKVPKrgUYThTA7ughcKMfjW6/X92H0wXGJoda0Apw4O5xbXL0GMLz40DTWlsb5hh2iItk6+XL72uJdxYcw==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.2.0", + "@smithy/core": "^3.24.1", "tslib": "^2.6.2" }, "engines": { @@ -11065,11 +11249,12 @@ } }, "node_modules/@smithy/util-config-provider": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.0.tgz", - "integrity": "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.3.1.tgz", + "integrity": "sha512-lw6L5GF5+W19vO6o3fZwRT2cXEG+8b2LH0b9ppjDT6nIxjUgmljEQGninx5XorylwKZZ4XLVABeroJ8oaF9RmQ==", "license": "Apache-2.0", "dependencies": { + "@smithy/core": "^3.24.1", "tslib": "^2.6.2" }, "engines": { @@ -11077,14 +11262,12 @@ } }, "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.3.32", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.32.tgz", - "integrity": "sha512-092sjYfFMQ/iaPH798LY/OJFBcYu0sSK34Oy9vdixhsU36zlZu8OcYjF3TD4e2ARupyK7xaxPXl+T0VIJTEkkg==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.4.1.tgz", + "integrity": "sha512-1rA7w+LjK1WJClsffC81Z/ZtjFt22QsKhBjUYEnZsGVS2nOTfOENKBzdg4SxhdwFvBCjcbpjscUfXOPwE3UHWQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.8", - "@smithy/smithy-client": "^4.11.5", - "@smithy/types": "^4.12.0", + "@smithy/core": "^3.24.1", "tslib": "^2.6.2" }, "engines": { @@ -11092,17 +11275,12 @@ } }, "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.2.35", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.35.tgz", - "integrity": "sha512-miz/ggz87M8VuM29y7jJZMYkn7+IErM5p5UgKIf8OtqVs/h2bXr1Bt3uTsREsI/4nK8a0PQERbAPsVPVNIsG7Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.3.1.tgz", + "integrity": "sha512-1fk1wfQHBenQD5NitVKOFgW0wsISYAFPIXGyStJWAeCtMyRhgHYvtJxBk2rwGWA0L5QX6oM6yeHSLKPFMk59ww==", "license": "Apache-2.0", "dependencies": { - "@smithy/config-resolver": "^4.4.6", - "@smithy/credential-provider-imds": "^4.2.8", - "@smithy/node-config-provider": "^4.3.8", - "@smithy/property-provider": "^4.2.8", - "@smithy/smithy-client": "^4.11.5", - "@smithy/types": "^4.12.0", + "@smithy/core": "^3.24.1", "tslib": "^2.6.2" }, "engines": { @@ -11110,25 +11288,12 @@ } }, "node_modules/@smithy/util-endpoints": { - "version": "3.2.8", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.8.tgz", - "integrity": "sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.8", - "@smithy/types": "^4.12.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-hex-encoding": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz", - "integrity": "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.5.1.tgz", + "integrity": "sha512-yORYzJD5zoGbSDkAACr0dIjDiSEA3X8h8lggDENl1dkKpCG0TQIoItPBqtvuJHzFFjRXumcoH+/09xIuixGyCw==", "license": "Apache-2.0", "dependencies": { + "@smithy/core": "^3.24.1", "tslib": "^2.6.2" }, "engines": { @@ -11136,12 +11301,12 @@ } }, "node_modules/@smithy/util-middleware": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.8.tgz", - "integrity": "sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.3.1.tgz", + "integrity": "sha512-SRRMDcIgVXVhVbxviBaSZbuWuVW3jD08wv4ESV0V2oiw0Mki8TPVQ5IxwD3MvSTPg52QYsRP+JoMw5WdUdeWAg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.12.0", + "@smithy/core": "^3.24.1", "tslib": "^2.6.2" }, "engines": { @@ -11149,13 +11314,12 @@ } }, "node_modules/@smithy/util-retry": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.8.tgz", - "integrity": "sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.4.1.tgz", + "integrity": "sha512-qkgWgwn1xw0GoY9Ea/B6FrYSPfHA0zyOtJkokwxZuvucRf2+2lfTut6adi4e4Y7LEAaxsFG7r6i05mtDCxbHKA==", "license": "Apache-2.0", "dependencies": { - "@smithy/service-error-classification": "^4.2.8", - "@smithy/types": "^4.12.0", + "@smithy/core": "^3.24.1", "tslib": "^2.6.2" }, "engines": { @@ -11163,18 +11327,12 @@ } }, "node_modules/@smithy/util-stream": { - "version": "4.5.12", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.12.tgz", - "integrity": "sha512-D8tgkrmhAX/UNeCZbqbEO3uqyghUnEmmoO9YEvRuwxjlkKKUE7FOgCJnqpTlQPe9MApdWPky58mNQQHbnCzoNg==", + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.6.1.tgz", + "integrity": "sha512-GjZfEft0M0V3n2YM/LGkr5LeLd8gxHUIzW0rUz6VtTtlAq245GxHlJghvoPEjJHKTj255iHFAiA4IsIdK40Ueg==", "license": "Apache-2.0", "dependencies": { - "@smithy/fetch-http-handler": "^5.3.9", - "@smithy/node-http-handler": "^4.4.10", - "@smithy/types": "^4.12.0", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-buffer-from": "^4.2.0", - "@smithy/util-hex-encoding": "^4.2.0", - "@smithy/util-utf8": "^4.2.0", + "@smithy/core": "^3.24.1", "tslib": "^2.6.2" }, "engines": { @@ -11194,12 +11352,12 @@ } }, "node_modules/@smithy/util-utf8": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz", - "integrity": "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.3.1.tgz", + "integrity": "sha512-FtRrSnriXtOs4+J8/y9SbQ1xmN71hrOsN/YJr5PQQj5nR1l7YNkGS/TEk4gr0WN7gyrUqw8/RFaYVjI18732ZA==", "license": "Apache-2.0", "dependencies": { - "@smithy/util-buffer-from": "^4.2.0", + "@smithy/core": "^3.24.1", "tslib": "^2.6.2" }, "engines": { @@ -11207,29 +11365,26 @@ } }, "node_modules/@smithy/util-waiter": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.8.tgz", - "integrity": "sha512-n+lahlMWk+aejGuax7DPWtqav8HYnWxQwR+LCG2BgCUmaGcTe9qZCFsmw8TMg9iG75HOwhrJCX9TCJRLH+Yzqg==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.4.1.tgz", + "integrity": "sha512-G/gWDykZNL0NVcd1qXkoKm45jxJECp6q53DSomM5QKMsyAMEsGksVq+HwgonqYxfFJEzzHi6ljtWKXVS1pl0/Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.2.8", - "@smithy/types": "^4.12.0", + "@smithy/core": "^3.24.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/uuid": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.0.tgz", - "integrity": "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==", - "license": "Apache-2.0", + "node_modules/@so-ric/colorspace": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", + "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", + "license": "MIT", "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "color": "^5.0.2", + "text-hex": "1.0.x" } }, "node_modules/@storybook/addon-actions": { @@ -14291,6 +14446,19 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@techteamer/ocsp": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@techteamer/ocsp/-/ocsp-1.0.1.tgz", + "integrity": "sha512-q4pW5wAC6Pc3JI8UePwE37CkLQ5gDGZMgjSX4MEEm4D4Di59auDQ8UNIDzC4gRnPNmmcwjpPxozq8p5pjiOmOw==", + "license": "MIT", + "dependencies": { + "asn1.js": "^5.4.1", + "asn1.js-rfc2560": "^5.0.1", + "asn1.js-rfc5280": "^3.0.0", + "async": "^3.2.4", + "simple-lru-cache": "^0.0.2" + } + }, "node_modules/@tokenizer/token": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", @@ -15029,6 +15197,12 @@ "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", "dev": true }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", + "license": "MIT" + }, "node_modules/@types/unist": { "version": "2.0.10", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz", @@ -15268,6 +15442,55 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.5.tgz", + "integrity": "sha512-yURCknZhvywvQItHMMmFSo+fq5arCUIyz/CVk7jD89MSai7dkaX8ufjCWp3NttLojoTVbcE72ri+be/TnEbMHw==", + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@typespec/ts-http-runtime/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@typespec/ts-http-runtime/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@typespec/ts-http-runtime/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", @@ -15944,7 +16167,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, "dependencies": { "debug": "4" }, @@ -16360,6 +16582,39 @@ "dev": true, "license": "MIT" }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/asn1.js-rfc2560": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/asn1.js-rfc2560/-/asn1.js-rfc2560-5.0.1.tgz", + "integrity": "sha512-1PrVg6kuBziDN3PGFmRk3QrjpKvP9h/Hv5yMrFZvC1kpzP6dQRzf5BpKstANqHBkaOUmTpakJWhicTATOA/SbA==", + "license": "MIT", + "dependencies": { + "asn1.js-rfc5280": "^3.0.0" + }, + "peerDependencies": { + "asn1.js": "^5.0.0" + } + }, + "node_modules/asn1.js-rfc5280": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/asn1.js-rfc5280/-/asn1.js-rfc5280-3.0.0.tgz", + "integrity": "sha512-Y2LZPOWeZ6qehv698ZgOGGCZXBQShObWnGthTrIFlIQjuV1gg2B8QOhWFRExq/MR1VnPpIIe7P9vX2vElxv+Pg==", + "license": "MIT", + "dependencies": { + "asn1.js": "^5.0.0" + } + }, "node_modules/assert": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", @@ -16487,13 +16742,14 @@ } }, "node_modules/axios": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", - "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, @@ -16867,7 +17123,6 @@ "version": "1.6.52", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", - "dev": true, "engines": { "node": ">=0.6" } @@ -16881,6 +17136,15 @@ "node": "*" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/bin-check": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bin-check/-/bin-check-4.1.0.tgz", @@ -17002,6 +17266,12 @@ "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", "dev": true }, + "node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", @@ -17241,6 +17511,21 @@ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -17855,6 +18140,19 @@ "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==" }, + "node_modules/color": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", + "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", + "license": "MIT", + "dependencies": { + "color-convert": "^3.1.3", + "color-string": "^2.1.3" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -17871,6 +18169,48 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, + "node_modules/color-string": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", + "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-string/node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/color/node_modules/color-convert": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", + "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=14.6" + } + }, + "node_modules/color/node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, "node_modules/color2k": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/color2k/-/color2k-2.0.3.tgz", @@ -19491,7 +19831,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "dev": true, "engines": { "node": ">= 12" } @@ -19659,6 +19998,22 @@ "node": ">=0.10.0" } }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/default-browser-id": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-3.0.0.tgz", @@ -19675,6 +20030,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/default-browser/node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/default-gateway": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-6.0.3.tgz", @@ -20289,6 +20656,12 @@ "node": ">= 4" } }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", + "license": "MIT" + }, "node_modules/encode-registry": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/encode-registry/-/encode-registry-3.0.1.tgz", @@ -21243,7 +21616,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, "engines": { "node": ">=0.8.x" } @@ -21367,6 +21739,18 @@ "node": ">= 0.8.0" } }, + "node_modules/expand-tilde": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz", + "integrity": "sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==", + "license": "MIT", + "dependencies": { + "homedir-polyfill": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/expect": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", @@ -21563,8 +21947,7 @@ "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" }, "node_modules/extract-zip": { "version": "1.7.0", @@ -21637,10 +22020,26 @@ "integrity": "sha512-WvJe06IfNYlr+6cO3uQkdKdy3Cb1LlCJSF8zRs2eT8yuhdbSlR9nIt+TgQ92RUxiRrQm+/S7RARnMfCs5iuAjw==", "dev": true }, + "node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, "node_modules/fast-xml-parser": { - "version": "5.3.6", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.6.tgz", - "integrity": "sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.2.tgz", + "integrity": "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==", "funding": [ { "type": "github", @@ -21649,7 +22048,10 @@ ], "license": "MIT", "dependencies": { - "strnum": "^2.1.2" + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.5", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" @@ -21659,7 +22061,6 @@ "version": "1.0.16", "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", - "dev": true, "engines": { "node": ">= 4.9.1" } @@ -21701,11 +22102,16 @@ "pend": "~1.2.0" } }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", + "license": "MIT" + }, "node_modules/fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "dev": true, "funding": [ { "type": "github", @@ -22091,6 +22497,12 @@ "node": ">=0.4.0" } }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", + "license": "MIT" + }, "node_modules/focus-lock": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/focus-lock/-/focus-lock-1.3.6.tgz", @@ -22104,9 +22516,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -22266,7 +22678,6 @@ "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "dev": true, "dependencies": { "fetch-blob": "^3.1.2" }, @@ -22518,6 +22929,83 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gaxios/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/generic-pool": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -22846,6 +23334,53 @@ "csstype": "^3.0.10" } }, + "node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-auth-library/node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/google-auth-library/node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -23106,6 +23641,18 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/homedir-polyfill": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", + "integrity": "sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==", + "license": "MIT", + "dependencies": { + "parse-passwd": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/hosted-git-info": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", @@ -23355,7 +23902,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "dev": true, "dependencies": { "agent-base": "6", "debug": "4" @@ -23820,6 +24366,39 @@ "node": ">=0.10.0" } }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container/node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-interactive": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", @@ -25135,6 +25714,15 @@ "node": ">=4" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -25314,6 +25902,12 @@ "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", "dev": true }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "license": "MIT" + }, "node_modules/language-subtag-registry": { "version": "0.3.23", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", @@ -25729,6 +26323,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/logform": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "license": "MIT", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/logform/node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -26152,8 +26772,7 @@ "node_modules/minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "dev": true + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" }, "node_modules/minimatch": { "version": "9.0.3", @@ -26246,6 +26865,27 @@ "ufo": "^1.5.3" } }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/moment-timezone": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz", + "integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==", + "license": "MIT", + "dependencies": { + "moment": "^2.29.4" + }, + "engines": { + "node": "*" + } + }, "node_modules/mrmime": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", @@ -26472,7 +27112,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "dev": true, "funding": [ { "type": "github", @@ -26970,6 +27609,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/oauth4webapi": { + "version": "3.8.6", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz", + "integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -27153,6 +27801,15 @@ "wrappy": "1" } }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "license": "MIT", + "dependencies": { + "fn.name": "1.x.x" + } + }, "node_modules/onetime": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", @@ -27473,6 +28130,15 @@ "node": ">= 0.10" } }, + "node_modules/parse-passwd": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz", + "integrity": "sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/parse5": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", @@ -27552,6 +28218,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -29813,6 +30494,18 @@ "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==", "dev": true }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -29889,6 +30582,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -30335,6 +31037,11 @@ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" }, + "node_modules/simple-lru-cache": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/simple-lru-cache/-/simple-lru-cache-0.0.2.tgz", + "integrity": "sha512-uEv/AFO0ADI7d99OHDmh1QfYzQk/izT1vCmu/riQfh7qjBVUUgRT87E5s5h7CxWCA/+YoZerykpEthzVrW3LIw==" + }, "node_modules/sirv": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", @@ -30387,6 +31094,155 @@ "tslib": "^2.0.3" } }, + "node_modules/snowflake-sdk": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/snowflake-sdk/-/snowflake-sdk-2.4.1.tgz", + "integrity": "sha512-JIdqz9ed2FzkU8oEstf06hTJRoX9+PRRG9LJT1vfGTXN3A52kGxhGoWzmK0GtFTUnxTMxMoMYgD5QdoQbckyag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-sdk/client-s3": "~3.1045.0", + "@aws-sdk/client-sts": "~3.1045.0", + "@aws-sdk/credential-provider-node": "~3.972.37", + "@aws-sdk/ec2-metadata-service": "~3.1045.0", + "@azure/identity": "^4.10.1", + "@azure/storage-blob": "12.26.x", + "@smithy/node-http-handler": "^4.4.9", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", + "@techteamer/ocsp": "1.0.1", + "asn1.js": "^5.0.0", + "asn1.js-rfc2560": "^5.0.0", + "asn1.js-rfc5280": "^3.0.0", + "axios": "^1.15.1", + "big-integer": "^1.6.43", + "bignumber.js": "^9.1.2", + "expand-tilde": "^2.0.2", + "fast-xml-parser": "^5.4.1", + "fastest-levenshtein": "^1.0.16", + "generic-pool": "^3.8.2", + "google-auth-library": "^10.1.0", + "https-proxy-agent": "^7.0.2", + "jsonwebtoken": "^9.0.3", + "mime-types": "^2.1.29", + "moment": "^2.29.4", + "moment-timezone": "^0.5.15", + "oauth4webapi": "^3.0.1", + "open": "^7.3.1", + "simple-lru-cache": "^0.0.2", + "toml": "^3.0.0", + "winston": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "asn1.js": "^5.4.1" + } + }, + "node_modules/snowflake-sdk/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/snowflake-sdk/node_modules/fast-xml-parser": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.8.0.tgz", + "integrity": "sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.2.0", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.3.0", + "xml-naming": "^0.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/snowflake-sdk/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/snowflake-sdk/node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/snowflake-sdk/node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/snowflake-sdk/node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/snowflake-sdk/node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/sockjs": { "version": "0.3.24", "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", @@ -30553,6 +31409,15 @@ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==" }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -30887,9 +31752,9 @@ } }, "node_modules/strnum": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", - "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", "funding": [ { "type": "github", @@ -31564,6 +32429,12 @@ "node": "*" } }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", + "license": "MIT" + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -31723,6 +32594,12 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/toml": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz", + "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==", + "license": "MIT" + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -31792,6 +32669,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, "node_modules/ts-api-utils": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", @@ -32943,7 +33829,6 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "dev": true, "engines": { "node": ">= 8" } @@ -33464,6 +34349,63 @@ "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", "dev": true }, + "node_modules/winston": { + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", + "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", + "license": "MIT", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.8", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "license": "MIT", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston/node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/winston/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -33550,6 +34492,36 @@ } } }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wsl-utils/node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/xml-name-validator": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", @@ -33559,6 +34531,21 @@ "node": ">=12" } }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", diff --git a/app/package.json b/app/package.json index 03e9f273..efbf7ed6 100644 --- a/app/package.json +++ b/app/package.json @@ -86,6 +86,7 @@ "react-icons": "^5.2.1", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.0", + "snowflake-sdk": "^2.4.1", "tslib": "^2.3.0" }, "devDependencies": { From 3f73bb4b37202d1e38414af89ba6f87b759b1edf Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Wed, 13 May 2026 16:14:24 -0500 Subject: [PATCH 08/46] wire cross-year roster query from edu connection info Move the Snowflake staging schema into the EDU connection payload, use the real cross-year roster query, and make partner-specific AWS secrets take precedence over local env fallbacks. This keeps Snowflake config in one place and restores the intended secret-first behavior for deployed environments. Co-authored-by: Codex --- .../integration/tests/earthbeam-api.spec.ts | 4 ++ app/api/integration/tests/edu-config.spec.ts | 4 ++ app/api/src/config/app-config.service.ts | 62 ++++++++++++------- .../earthbeam/api/earthbeam-api.service.ts | 33 +++++++++- 4 files changed, 77 insertions(+), 26 deletions(-) diff --git a/app/api/integration/tests/earthbeam-api.spec.ts b/app/api/integration/tests/earthbeam-api.spec.ts index 2ca0bf08..b2005bcb 100644 --- a/app/api/integration/tests/earthbeam-api.spec.ts +++ b/app/api/integration/tests/earthbeam-api.spec.ts @@ -85,6 +85,7 @@ describe('Earthbeam API', () => { const EDU_ENV_VARS = [ 'EDU_SNOWFLAKE_USERNAME', 'EDU_SNOWFLAKE_URL', + 'EDU_SNOWFLAKE_SCHEMA', 'EDU_SNOWFLAKE_PUBLIC_KEY', 'EDU_SNOWFLAKE_PRIVATE_KEY', ] as const; @@ -93,6 +94,7 @@ describe('Earthbeam API', () => { const setEduEnvVars = () => { process.env.EDU_SNOWFLAKE_USERNAME = 'snowflake-user'; process.env.EDU_SNOWFLAKE_URL = 'https://example.snowflakecomputing.com'; + process.env.EDU_SNOWFLAKE_SCHEMA = 'edu_stg.public'; process.env.EDU_SNOWFLAKE_PUBLIC_KEY = Buffer.from('pub').toString('base64'); process.env.EDU_SNOWFLAKE_PRIVATE_KEY = Buffer.from('priv').toString('base64'); }; @@ -321,6 +323,7 @@ describe('Earthbeam API', () => { const EDU_ENV_VARS = [ 'EDU_SNOWFLAKE_USERNAME', 'EDU_SNOWFLAKE_URL', + 'EDU_SNOWFLAKE_SCHEMA', 'EDU_SNOWFLAKE_PUBLIC_KEY', 'EDU_SNOWFLAKE_PRIVATE_KEY', ] as const; @@ -329,6 +332,7 @@ describe('Earthbeam API', () => { const setEduEnvVars = () => { process.env.EDU_SNOWFLAKE_USERNAME = 'snowflake-user'; process.env.EDU_SNOWFLAKE_URL = 'https://example.snowflakecomputing.com'; + process.env.EDU_SNOWFLAKE_SCHEMA = 'edu_stg.public'; process.env.EDU_SNOWFLAKE_PUBLIC_KEY = Buffer.from('pub').toString('base64'); process.env.EDU_SNOWFLAKE_PRIVATE_KEY = Buffer.from('priv').toString('base64'); }; diff --git a/app/api/integration/tests/edu-config.spec.ts b/app/api/integration/tests/edu-config.spec.ts index 34e051ed..9bfcc5b7 100644 --- a/app/api/integration/tests/edu-config.spec.ts +++ b/app/api/integration/tests/edu-config.spec.ts @@ -4,6 +4,7 @@ import { partnerA } from '../fixtures/context-fixtures/partner-fixtures'; const EDU_ENV_VARS = [ 'EDU_SNOWFLAKE_USERNAME', 'EDU_SNOWFLAKE_URL', + 'EDU_SNOWFLAKE_SCHEMA', 'EDU_SNOWFLAKE_PUBLIC_KEY', 'EDU_SNOWFLAKE_PRIVATE_KEY', ] as const; @@ -42,6 +43,7 @@ describe('AppConfigService — EDU Snowflake config', () => { it('returns true when local env vars are set', async () => { process.env.EDU_SNOWFLAKE_USERNAME = 'snowflake-user'; process.env.EDU_SNOWFLAKE_URL = 'https://example.snowflakecomputing.com'; + process.env.EDU_SNOWFLAKE_SCHEMA = 'edu_stg.public'; process.env.EDU_SNOWFLAKE_PUBLIC_KEY = Buffer.from('public-key').toString('base64'); process.env.EDU_SNOWFLAKE_PRIVATE_KEY = Buffer.from('private-key').toString('base64'); @@ -61,6 +63,7 @@ describe('AppConfigService — EDU Snowflake config', () => { const publicKey = Buffer.from('public-key-content').toString('base64'); process.env.EDU_SNOWFLAKE_USERNAME = 'snowflake-user'; process.env.EDU_SNOWFLAKE_URL = 'https://example.snowflakecomputing.com'; + process.env.EDU_SNOWFLAKE_SCHEMA = 'edu_stg.public'; process.env.EDU_SNOWFLAKE_PUBLIC_KEY = publicKey; process.env.EDU_SNOWFLAKE_PRIVATE_KEY = privateKey; @@ -68,6 +71,7 @@ describe('AppConfigService — EDU Snowflake config', () => { expect(info).toEqual({ username: 'snowflake-user', url: 'https://example.snowflakecomputing.com', + schema: 'edu_stg.public', publicKey: Buffer.from('public-key-content'), privateKey: Buffer.from('private-key-content'), }); diff --git a/app/api/src/config/app-config.service.ts b/app/api/src/config/app-config.service.ts index 6d77e703..2e7cbe28 100644 --- a/app/api/src/config/app-config.service.ts +++ b/app/api/src/config/app-config.service.ts @@ -11,6 +11,7 @@ type ParameterWithNameAndValue = Required>; export type EduConnectionInfo = { username: string; url: string; + schema: string; publicKey: Buffer; privateKey: Buffer; }; @@ -106,45 +107,29 @@ export class AppConfigService { /** * EDU Snowflake connection info for cross-year ID matching. Looks for an * AWS secret named `edu-connection-info-`; falls back to - * EDU_SNOWFLAKE_* env vars when the secret is unavailable (local dev). + * EDU_SNOWFLAKE_* env vars only outside production when the secret is unavailable. * Returns null when no creds are available — caller decides how to handle. */ async getEduConnectionInfo(partnerId: string): Promise { - // Local-dev path first so tests / local runs never hit AWS. - const envUsername = process.env.EDU_SNOWFLAKE_USERNAME; - if (envUsername) { - const url = process.env.EDU_SNOWFLAKE_URL; - const publicKeyB64 = process.env.EDU_SNOWFLAKE_PUBLIC_KEY; - const privateKeyB64 = process.env.EDU_SNOWFLAKE_PRIVATE_KEY; - if (!url || !publicKeyB64 || !privateKeyB64) { - return null; - } - return { - username: envUsername, - url, - publicKey: Buffer.from(publicKeyB64, 'base64'), - privateKey: Buffer.from(privateKeyB64, 'base64'), - }; - } - const secretName = `edu-connection-info-${partnerId}`; try { const secret = await this.getAWSSecret(secretName); if (typeof secret !== 'object') { - return null; + return this.getEduConnectionInfoFromEnv(); } - const { username, url, publicKey, privateKey } = secret; - if (!username || !url || !publicKey || !privateKey) { - return null; + const { username, url, schema, publicKey, privateKey } = secret; + if (!username || !url || !schema || !publicKey || !privateKey) { + return this.getEduConnectionInfoFromEnv(); } return { username, url, + schema: this.validateSnowflakeIdentifier(schema, `edu-connection-info-${partnerId}.schema`), publicKey: Buffer.from(publicKey, 'base64'), privateKey: Buffer.from(privateKey, 'base64'), }; } catch { - return null; + return this.getEduConnectionInfoFromEnv(); } } @@ -255,6 +240,37 @@ export class AppConfigService { private readonly secretsCache: Map> = new Map(); + private getEduConnectionInfoFromEnv(): EduConnectionInfo | null { + if (this.get('NODE_ENV') === 'production') { + return null; + } + + const envUsername = process.env.EDU_SNOWFLAKE_USERNAME; + const url = process.env.EDU_SNOWFLAKE_URL; + const schema = process.env.EDU_SNOWFLAKE_SCHEMA; + const publicKeyB64 = process.env.EDU_SNOWFLAKE_PUBLIC_KEY; + const privateKeyB64 = process.env.EDU_SNOWFLAKE_PRIVATE_KEY; + if (!envUsername || !url || !schema || !publicKeyB64 || !privateKeyB64) { + return null; + } + + return { + username: envUsername, + url, + schema: this.validateSnowflakeIdentifier(schema, 'EDU_SNOWFLAKE_SCHEMA'), + publicKey: Buffer.from(publicKeyB64, 'base64'), + privateKey: Buffer.from(privateKeyB64, 'base64'), + }; + } + + private validateSnowflakeIdentifier(identifier: string, sourceName: string): string { + if (!/^[A-Za-z0-9_$]+(\.[A-Za-z0-9_$]+)*$/.test(identifier)) { + throw new Error(`Invalid ${sourceName}: ${identifier}`); + } + + return identifier; + } + private async getAWSSecret(secretName: string): Promise> { const cachedValue = this.secretsCache.get(secretName); if (cachedValue) { diff --git a/app/api/src/earthbeam/api/earthbeam-api.service.ts b/app/api/src/earthbeam/api/earthbeam-api.service.ts index 193ec8b4..3a52cf00 100644 --- a/app/api/src/earthbeam/api/earthbeam-api.service.ts +++ b/app/api/src/earthbeam/api/earthbeam-api.service.ts @@ -113,9 +113,36 @@ export class EarthbeamApiService { connection.connect((err) => (err ? reject(err) : resolve())); }); - // TODO: replace with data-engineer-supplied query. Must accept tenant_code - // as its single bind parameter and emit JSON-shaped rows. - const sqlText = 'SELECT :1 AS tenant_code /* TODO: real cross-year roster query */'; + const sqlText = ` + WITH ids AS ( + SELECT + seoa.tenant_code, + seoa.api_year, + seoa.k_student, + seoa.k_student_xyear, + seoa.student_unique_id, + seoa.ed_org_id, + seo_ids.id_system, + OBJECT_CONSTRUCT_KEEP_NULL( + 'studentIdentificationSystemDescriptor', seo_ids.id_system, + 'identificationCode', seo_ids.id_code + ) AS stu_id_code + FROM ${conn.schema}.stg_ef3__student_education_organization_associations seoa + LEFT JOIN ${conn.schema}.stg_ef3__stu_ed_org__identification_codes seo_ids + ON seoa.k_student = seo_ids.k_student + WHERE seoa.tenant_code = :1 + QUALIFY MAX(seoa.api_year) OVER (PARTITION BY seoa.k_student_xyear) = seoa.api_year + ) + SELECT + OBJECT_CONSTRUCT( + 'educationOrganizationId', ed_org_id, + 'link', OBJECT_CONSTRUCT('rel', 'LocalEducationAgency') + ) AS "educationOrganizationReference", + OBJECT_CONSTRUCT('studentUniqueId', student_unique_id) AS "studentReference", + ARRAY_AGG(DISTINCT stu_id_code) AS "studentIdentificationCodes" + FROM ids + GROUP BY ALL + `; await new Promise((resolve, reject) => { const stmt = connection.execute({ From b7393f247c7cfb84e57457472b4f2810cca0b1ba Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Wed, 13 May 2026 16:20:19 -0500 Subject: [PATCH 09/46] limit edu env fallback to local development Use NODE_ENV to gate the EDU Snowflake env-var fallback so deployed environments rely on partner-specific AWS secrets. If the secret is missing or incomplete outside local development, return null instead of silently falling back. Co-authored-by: Codex --- app/api/src/config/app-config.service.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/api/src/config/app-config.service.ts b/app/api/src/config/app-config.service.ts index 2e7cbe28..7695a9c6 100644 --- a/app/api/src/config/app-config.service.ts +++ b/app/api/src/config/app-config.service.ts @@ -107,19 +107,23 @@ export class AppConfigService { /** * EDU Snowflake connection info for cross-year ID matching. Looks for an * AWS secret named `edu-connection-info-`; falls back to - * EDU_SNOWFLAKE_* env vars only outside production when the secret is unavailable. + * EDU_SNOWFLAKE_* env vars only in local development. * Returns null when no creds are available — caller decides how to handle. */ async getEduConnectionInfo(partnerId: string): Promise { + if (this.isDevEnvironment()) { + return this.getEduConnectionInfoFromEnv(); + } + const secretName = `edu-connection-info-${partnerId}`; try { const secret = await this.getAWSSecret(secretName); if (typeof secret !== 'object') { - return this.getEduConnectionInfoFromEnv(); + return null; } const { username, url, schema, publicKey, privateKey } = secret; if (!username || !url || !schema || !publicKey || !privateKey) { - return this.getEduConnectionInfoFromEnv(); + return null; } return { username, @@ -129,7 +133,7 @@ export class AppConfigService { privateKey: Buffer.from(privateKey, 'base64'), }; } catch { - return this.getEduConnectionInfoFromEnv(); + return null; } } @@ -241,10 +245,6 @@ export class AppConfigService { private readonly secretsCache: Map> = new Map(); private getEduConnectionInfoFromEnv(): EduConnectionInfo | null { - if (this.get('NODE_ENV') === 'production') { - return null; - } - const envUsername = process.env.EDU_SNOWFLAKE_USERNAME; const url = process.env.EDU_SNOWFLAKE_URL; const schema = process.env.EDU_SNOWFLAKE_SCHEMA; From 1c94617a780ac7b111ac19d2cfbc1e06e72137bf Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Wed, 13 May 2026 16:22:02 -0500 Subject: [PATCH 10/46] document local edu snowflake env vars Add the local-development EDU Snowflake fallback variables to the API env template so cross-year roster testing can be configured without guessing the required keys. Co-authored-by: Codex --- app/api/.env.copyme | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/api/.env.copyme b/app/api/.env.copyme index 02f8f815..e6e10171 100644 --- a/app/api/.env.copyme +++ b/app/api/.env.copyme @@ -27,5 +27,12 @@ LOCAL_EVENT_EMITTER=log # log | noop (omit for EventBridge) AWS_REGION=us-east-2 BUNDLE_BRANCH=development +# EDU Snowflake cross-year roster fallback for local development only +EDU_SNOWFLAKE_USERNAME= +EDU_SNOWFLAKE_URL= +EDU_SNOWFLAKE_SCHEMA= +EDU_SNOWFLAKE_PUBLIC_KEY= +EDU_SNOWFLAKE_PRIVATE_KEY= + OAUTH2_ISSUER=http://localhost:8080/realms/example OAUTH2_AUDIENCE=runway-local From 12c1a7928421c752e290164a6d2352e19f13e953 Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Wed, 13 May 2026 16:49:09 -0500 Subject: [PATCH 11/46] document edu key encoding in env template Clarify in the API env template that the local EDU Snowflake key values should contain base64-encoded PEM contents, with quick examples for file and clipboard encoding. Co-authored-by: Codex --- app/api/.env.copyme | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/api/.env.copyme b/app/api/.env.copyme index e6e10171..e5dbe96c 100644 --- a/app/api/.env.copyme +++ b/app/api/.env.copyme @@ -28,6 +28,9 @@ AWS_REGION=us-east-2 BUNDLE_BRANCH=development # EDU Snowflake cross-year roster fallback for local development only +# Public/private key values should be base64-encoded PEM contents. +# Example from files: `base64 -w 0 public_key.pem` and `base64 -w 0 private_key.pem` +# Example from clipboard on Linux: `xclip -selection clipboard -o | base64 -w 0` EDU_SNOWFLAKE_USERNAME= EDU_SNOWFLAKE_URL= EDU_SNOWFLAKE_SCHEMA= From 148b0ca4bb7c8c33e0e48f5fb53cfb87830a370f Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Thu, 14 May 2026 13:30:07 -0500 Subject: [PATCH 12/46] fix snowflake account parsing for region-qualified urls The previous `hostname.split('.')[0]` only kept the leading subdomain, dropping the region segment for URLs like `..snowflakecomputing.com` and causing the SDK's JWT auth to fail. Strip the `.snowflakecomputing.com` suffix instead so the full account locator is preserved. Co-authored-by: Cursor --- app/api/src/earthbeam/api/earthbeam-api.service.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/api/src/earthbeam/api/earthbeam-api.service.ts b/app/api/src/earthbeam/api/earthbeam-api.service.ts index 3a52cf00..edfc7708 100644 --- a/app/api/src/earthbeam/api/earthbeam-api.service.ts +++ b/app/api/src/earthbeam/api/earthbeam-api.service.ts @@ -97,8 +97,9 @@ export class EarthbeamApiService { const snowflake = await import('snowflake-sdk'); // snowflake-sdk wants `account`, not a URL. URL is like - // https://..snowflakecomputing.com — take the leading subdomain. - const account = new URL(conn.url).hostname.split('.')[0]; + // https://.snowflakecomputing.com, where may itself + // contain dots (e.g. myorg.us-east-1). Strip the suffix and keep the rest. + const account = new URL(conn.url).hostname.replace(/\.snowflakecomputing\.com$/, ''); const connection = snowflake.createConnection({ account, username: conn.username, From f5c3a139f7272917b9469a09e45b83eda62d9463 Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Thu, 14 May 2026 14:05:56 -0500 Subject: [PATCH 13/46] also remove output-first-run for local executor --- app/api/src/earthbeam/executor/executor.local-python.service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/api/src/earthbeam/executor/executor.local-python.service.ts b/app/api/src/earthbeam/executor/executor.local-python.service.ts index 3737f179..06dfb5de 100644 --- a/app/api/src/earthbeam/executor/executor.local-python.service.ts +++ b/app/api/src/earthbeam/executor/executor.local-python.service.ts @@ -34,6 +34,7 @@ export class ExecutorLocalPythonService implements ExecutorService { // interfere with an already-running Executor process! But that's just a limitation of // this local mode: you can only run one Executor process at a time. await rm('../executor/local-run/output', { recursive: true, force: true }); + await rm('../executor/local-run/output-first-run', { recursive: true, force: true }); await rm('../executor/local-run/roster-download-dir', { recursive: true, force: true }); const initToken = await this.apiAuth.createInitToken({ runId: run.id }); From afc607c89349312390753c7b0d99628c969b7740 Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Thu, 14 May 2026 15:47:53 -0500 Subject: [PATCH 14/46] take snowflake account/database/schema directly from edu config Previously we accepted a Snowflake URL and parsed an `account` out of it, and used the schema by interpolating it into the SQL query. Switching to discrete `account`, `database`, and `schema` values that get passed to `snowflake.createConnection` removes the URL parsing, drops the SQL-identifier validator (no longer interpolated), and lets the SDK namespace the query. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/.env.copyme | 3 +- .../integration/tests/earthbeam-api.spec.ts | 16 ++++++---- app/api/integration/tests/edu-config.spec.ts | 18 +++++++---- app/api/src/config/app-config.service.ts | 32 ++++++++----------- .../earthbeam/api/earthbeam-api.service.ts | 12 +++---- 5 files changed, 42 insertions(+), 39 deletions(-) diff --git a/app/api/.env.copyme b/app/api/.env.copyme index e5dbe96c..c4b957bc 100644 --- a/app/api/.env.copyme +++ b/app/api/.env.copyme @@ -32,7 +32,8 @@ BUNDLE_BRANCH=development # Example from files: `base64 -w 0 public_key.pem` and `base64 -w 0 private_key.pem` # Example from clipboard on Linux: `xclip -selection clipboard -o | base64 -w 0` EDU_SNOWFLAKE_USERNAME= -EDU_SNOWFLAKE_URL= +EDU_SNOWFLAKE_ACCOUNT= +EDU_SNOWFLAKE_DATABASE= EDU_SNOWFLAKE_SCHEMA= EDU_SNOWFLAKE_PUBLIC_KEY= EDU_SNOWFLAKE_PRIVATE_KEY= diff --git a/app/api/integration/tests/earthbeam-api.spec.ts b/app/api/integration/tests/earthbeam-api.spec.ts index b2005bcb..60a3791c 100644 --- a/app/api/integration/tests/earthbeam-api.spec.ts +++ b/app/api/integration/tests/earthbeam-api.spec.ts @@ -84,7 +84,8 @@ describe('Earthbeam API', () => { describe('cross-year ID matching', () => { const EDU_ENV_VARS = [ 'EDU_SNOWFLAKE_USERNAME', - 'EDU_SNOWFLAKE_URL', + 'EDU_SNOWFLAKE_ACCOUNT', + 'EDU_SNOWFLAKE_DATABASE', 'EDU_SNOWFLAKE_SCHEMA', 'EDU_SNOWFLAKE_PUBLIC_KEY', 'EDU_SNOWFLAKE_PRIVATE_KEY', @@ -93,8 +94,9 @@ describe('Earthbeam API', () => { const setEduEnvVars = () => { process.env.EDU_SNOWFLAKE_USERNAME = 'snowflake-user'; - process.env.EDU_SNOWFLAKE_URL = 'https://example.snowflakecomputing.com'; - process.env.EDU_SNOWFLAKE_SCHEMA = 'edu_stg.public'; + process.env.EDU_SNOWFLAKE_ACCOUNT = 'example'; + process.env.EDU_SNOWFLAKE_DATABASE = 'edu_stg'; + process.env.EDU_SNOWFLAKE_SCHEMA = 'public'; process.env.EDU_SNOWFLAKE_PUBLIC_KEY = Buffer.from('pub').toString('base64'); process.env.EDU_SNOWFLAKE_PRIVATE_KEY = Buffer.from('priv').toString('base64'); }; @@ -322,7 +324,8 @@ describe('Earthbeam API', () => { const EDU_ENV_VARS = [ 'EDU_SNOWFLAKE_USERNAME', - 'EDU_SNOWFLAKE_URL', + 'EDU_SNOWFLAKE_ACCOUNT', + 'EDU_SNOWFLAKE_DATABASE', 'EDU_SNOWFLAKE_SCHEMA', 'EDU_SNOWFLAKE_PUBLIC_KEY', 'EDU_SNOWFLAKE_PRIVATE_KEY', @@ -331,8 +334,9 @@ describe('Earthbeam API', () => { const setEduEnvVars = () => { process.env.EDU_SNOWFLAKE_USERNAME = 'snowflake-user'; - process.env.EDU_SNOWFLAKE_URL = 'https://example.snowflakecomputing.com'; - process.env.EDU_SNOWFLAKE_SCHEMA = 'edu_stg.public'; + process.env.EDU_SNOWFLAKE_ACCOUNT = 'example'; + process.env.EDU_SNOWFLAKE_DATABASE = 'edu_stg'; + process.env.EDU_SNOWFLAKE_SCHEMA = 'public'; process.env.EDU_SNOWFLAKE_PUBLIC_KEY = Buffer.from('pub').toString('base64'); process.env.EDU_SNOWFLAKE_PRIVATE_KEY = Buffer.from('priv').toString('base64'); }; diff --git a/app/api/integration/tests/edu-config.spec.ts b/app/api/integration/tests/edu-config.spec.ts index 9bfcc5b7..52bca772 100644 --- a/app/api/integration/tests/edu-config.spec.ts +++ b/app/api/integration/tests/edu-config.spec.ts @@ -3,7 +3,8 @@ import { partnerA } from '../fixtures/context-fixtures/partner-fixtures'; const EDU_ENV_VARS = [ 'EDU_SNOWFLAKE_USERNAME', - 'EDU_SNOWFLAKE_URL', + 'EDU_SNOWFLAKE_ACCOUNT', + 'EDU_SNOWFLAKE_DATABASE', 'EDU_SNOWFLAKE_SCHEMA', 'EDU_SNOWFLAKE_PUBLIC_KEY', 'EDU_SNOWFLAKE_PRIVATE_KEY', @@ -42,8 +43,9 @@ describe('AppConfigService — EDU Snowflake config', () => { it('returns true when local env vars are set', async () => { process.env.EDU_SNOWFLAKE_USERNAME = 'snowflake-user'; - process.env.EDU_SNOWFLAKE_URL = 'https://example.snowflakecomputing.com'; - process.env.EDU_SNOWFLAKE_SCHEMA = 'edu_stg.public'; + process.env.EDU_SNOWFLAKE_ACCOUNT = 'example'; + process.env.EDU_SNOWFLAKE_DATABASE = 'edu_stg'; + process.env.EDU_SNOWFLAKE_SCHEMA = 'public'; process.env.EDU_SNOWFLAKE_PUBLIC_KEY = Buffer.from('public-key').toString('base64'); process.env.EDU_SNOWFLAKE_PRIVATE_KEY = Buffer.from('private-key').toString('base64'); @@ -62,16 +64,18 @@ describe('AppConfigService — EDU Snowflake config', () => { const privateKey = Buffer.from('private-key-content').toString('base64'); const publicKey = Buffer.from('public-key-content').toString('base64'); process.env.EDU_SNOWFLAKE_USERNAME = 'snowflake-user'; - process.env.EDU_SNOWFLAKE_URL = 'https://example.snowflakecomputing.com'; - process.env.EDU_SNOWFLAKE_SCHEMA = 'edu_stg.public'; + process.env.EDU_SNOWFLAKE_ACCOUNT = 'example'; + process.env.EDU_SNOWFLAKE_DATABASE = 'edu_stg'; + process.env.EDU_SNOWFLAKE_SCHEMA = 'public'; process.env.EDU_SNOWFLAKE_PUBLIC_KEY = publicKey; process.env.EDU_SNOWFLAKE_PRIVATE_KEY = privateKey; const info = await configService.getEduConnectionInfo(partnerA.id); expect(info).toEqual({ username: 'snowflake-user', - url: 'https://example.snowflakecomputing.com', - schema: 'edu_stg.public', + account: 'example', + database: 'edu_stg', + schema: 'public', publicKey: Buffer.from('public-key-content'), privateKey: Buffer.from('private-key-content'), }); diff --git a/app/api/src/config/app-config.service.ts b/app/api/src/config/app-config.service.ts index 7695a9c6..388c91cc 100644 --- a/app/api/src/config/app-config.service.ts +++ b/app/api/src/config/app-config.service.ts @@ -10,7 +10,8 @@ type ParameterWithNameAndValue = Required>; export type EduConnectionInfo = { username: string; - url: string; + account: string; + database: string; schema: string; publicKey: Buffer; privateKey: Buffer; @@ -121,14 +122,15 @@ export class AppConfigService { if (typeof secret !== 'object') { return null; } - const { username, url, schema, publicKey, privateKey } = secret; - if (!username || !url || !schema || !publicKey || !privateKey) { + const { username, account, database, schema, publicKey, privateKey } = secret; + if (!username || !account || !database || !schema || !publicKey || !privateKey) { return null; } return { username, - url, - schema: this.validateSnowflakeIdentifier(schema, `edu-connection-info-${partnerId}.schema`), + account, + database, + schema, publicKey: Buffer.from(publicKey, 'base64'), privateKey: Buffer.from(privateKey, 'base64'), }; @@ -246,32 +248,26 @@ export class AppConfigService { private getEduConnectionInfoFromEnv(): EduConnectionInfo | null { const envUsername = process.env.EDU_SNOWFLAKE_USERNAME; - const url = process.env.EDU_SNOWFLAKE_URL; + const account = process.env.EDU_SNOWFLAKE_ACCOUNT; + const database = process.env.EDU_SNOWFLAKE_DATABASE; const schema = process.env.EDU_SNOWFLAKE_SCHEMA; const publicKeyB64 = process.env.EDU_SNOWFLAKE_PUBLIC_KEY; const privateKeyB64 = process.env.EDU_SNOWFLAKE_PRIVATE_KEY; - if (!envUsername || !url || !schema || !publicKeyB64 || !privateKeyB64) { + if (!envUsername || !account || !database || !schema || !publicKeyB64 || !privateKeyB64) { return null; } return { username: envUsername, - url, - schema: this.validateSnowflakeIdentifier(schema, 'EDU_SNOWFLAKE_SCHEMA'), + account, + database, + schema, publicKey: Buffer.from(publicKeyB64, 'base64'), privateKey: Buffer.from(privateKeyB64, 'base64'), }; } - private validateSnowflakeIdentifier(identifier: string, sourceName: string): string { - if (!/^[A-Za-z0-9_$]+(\.[A-Za-z0-9_$]+)*$/.test(identifier)) { - throw new Error(`Invalid ${sourceName}: ${identifier}`); - } - - return identifier; - } - - private async getAWSSecret(secretName: string): Promise> { +private async getAWSSecret(secretName: string): Promise> { const cachedValue = this.secretsCache.get(secretName); if (cachedValue) { return cachedValue; diff --git a/app/api/src/earthbeam/api/earthbeam-api.service.ts b/app/api/src/earthbeam/api/earthbeam-api.service.ts index edfc7708..fb56b891 100644 --- a/app/api/src/earthbeam/api/earthbeam-api.service.ts +++ b/app/api/src/earthbeam/api/earthbeam-api.service.ts @@ -96,13 +96,11 @@ export class EarthbeamApiService { // to pay on every app boot. Cross-year roster fetch is a rare hot path. const snowflake = await import('snowflake-sdk'); - // snowflake-sdk wants `account`, not a URL. URL is like - // https://.snowflakecomputing.com, where may itself - // contain dots (e.g. myorg.us-east-1). Strip the suffix and keep the rest. - const account = new URL(conn.url).hostname.replace(/\.snowflakecomputing\.com$/, ''); const connection = snowflake.createConnection({ - account, + account: conn.account, username: conn.username, + database: conn.database, + schema: conn.schema, authenticator: 'SNOWFLAKE_JWT', privateKey: conn.privateKey.toString('utf-8'), }); @@ -128,8 +126,8 @@ export class EarthbeamApiService { 'studentIdentificationSystemDescriptor', seo_ids.id_system, 'identificationCode', seo_ids.id_code ) AS stu_id_code - FROM ${conn.schema}.stg_ef3__student_education_organization_associations seoa - LEFT JOIN ${conn.schema}.stg_ef3__stu_ed_org__identification_codes seo_ids + FROM stg_ef3__student_education_organization_associations seoa + LEFT JOIN stg_ef3__stu_ed_org__identification_codes seo_ids ON seoa.k_student = seo_ids.k_student WHERE seoa.tenant_code = :1 QUALIFY MAX(seoa.api_year) OVER (PARTITION BY seoa.k_student_xyear) = seoa.api_year From 26bd2919e1fea16fb56d429447e288537aceab62 Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Thu, 14 May 2026 16:45:23 -0500 Subject: [PATCH 15/46] pool snowflake connections per partner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a Snowflake connection takes ~20s — paying that on every roster request was the dominant cost. Introduce EduSnowflakePoolService that keeps one snowflake-sdk pool per partner (min 1, max 4). On module init we fire-and-forget warm a pool for every partner with cross-year matching enabled, so first-request latency disappears for warm pools. Requests for partners without a warmed pool create one on demand and share the same in-flight promise across concurrent callers. Failed pool creation is evicted so the next request retries. streamCrossYearRoster now goes through eduPool.use(partnerId, cb) instead of opening and tearing down its own connection. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/earthbeam/api/earthbeam-api.module.ts | 3 +- .../earthbeam/api/earthbeam-api.service.ts | 44 +++------ .../api/edu-snowflake-pool.service.ts | 96 +++++++++++++++++++ 3 files changed, 109 insertions(+), 34 deletions(-) create mode 100644 app/api/src/earthbeam/api/edu-snowflake-pool.service.ts diff --git a/app/api/src/earthbeam/api/earthbeam-api.module.ts b/app/api/src/earthbeam/api/earthbeam-api.module.ts index 28c308d4..7db89898 100644 --- a/app/api/src/earthbeam/api/earthbeam-api.module.ts +++ b/app/api/src/earthbeam/api/earthbeam-api.module.ts @@ -7,6 +7,7 @@ import { AppConfigModule } from 'api/src/config/app-config.module'; import { AppConfigService } from 'api/src/config/app-config.service'; import { EarthbeamApiAuthModule } from './auth/earthbeam-api-auth.module'; import { EarthbeamApiService } from './earthbeam-api.service'; +import { EduSnowflakePoolService } from './edu-snowflake-pool.service'; import { FileModule } from 'api/src/files/file.module'; import { EventEmitterModule } from 'api/src/event-emitter/event-emitter.module'; @@ -26,7 +27,7 @@ import { EventEmitterModule } from 'api/src/event-emitter/event-emitter.module'; }), }), ], - providers: [EarthbeamApiService], + providers: [EarthbeamApiService, EduSnowflakePoolService], controllers: [EarthbeamApiController], exports: [], }) diff --git a/app/api/src/earthbeam/api/earthbeam-api.service.ts b/app/api/src/earthbeam/api/earthbeam-api.service.ts index fb56b891..601b7be7 100644 --- a/app/api/src/earthbeam/api/earthbeam-api.service.ts +++ b/app/api/src/earthbeam/api/earthbeam-api.service.ts @@ -31,6 +31,7 @@ import { EVENT_EMITTER_SERVICE, } from 'api/src/event-emitter/event-emitter.service'; import type { Response } from 'express'; +import { EduSnowflakePoolService } from './edu-snowflake-pool.service'; @Injectable() export class EarthbeamApiService { @@ -41,7 +42,8 @@ export class EarthbeamApiService { private readonly encryptionService: EncryptionService, private readonly fileService: FileService, private readonly configService: AppConfigService, - @Inject(EVENT_EMITTER_SERVICE) private readonly eventEmitter: EventEmitterService + @Inject(EVENT_EMITTER_SERVICE) private readonly eventEmitter: EventEmitterService, + private readonly eduPool: EduSnowflakePoolService ) {} async getCrossYearRosterContext(runId: Run['id']) { @@ -75,8 +77,9 @@ export class EarthbeamApiService { /** * Streams a cross-year roster from EDU/Snowflake to the response as NDJSON. - * Per-request connection, closed in `finally`. On stream error: abrupt close - * (no in-band sentinel) — the Executor detects truncation and fails the run. + * Uses a partner-scoped connection pool; on stream error the response is + * destroyed (no in-band sentinel) — the Executor detects truncation and + * fails the run. */ async streamCrossYearRoster({ partnerId, @@ -87,31 +90,9 @@ export class EarthbeamApiService { tenantCode: string; response: Response; }): Promise { - const conn = await this.configService.getEduConnectionInfo(partnerId); - if (!conn) { - throw new Error('EDU connection info missing — caller should have validated'); - } - - // Lazy import: snowflake-sdk has slow module-init side effects we don't want - // to pay on every app boot. Cross-year roster fetch is a rare hot path. - const snowflake = await import('snowflake-sdk'); - - const connection = snowflake.createConnection({ - account: conn.account, - username: conn.username, - database: conn.database, - schema: conn.schema, - authenticator: 'SNOWFLAKE_JWT', - privateKey: conn.privateKey.toString('utf-8'), - }); - const startedAt = Date.now(); let rowCount = 0; - try { - await new Promise((resolve, reject) => { - connection.connect((err) => (err ? reject(err) : resolve())); - }); - + await this.eduPool.use(partnerId, async (connection) => { const sqlText = ` WITH ids AS ( SELECT @@ -163,13 +144,10 @@ export class EarthbeamApiService { reject(err); }); }); - - this.logger.log( - `cross-year roster: partnerId=${partnerId} tenantCode=${tenantCode} rowCount=${rowCount} durationMs=${Date.now() - startedAt}` - ); - } finally { - connection.destroy(() => undefined); - } + }); + this.logger.log( + `cross-year roster: partnerId=${partnerId} tenantCode=${tenantCode} rowCount=${rowCount} durationMs=${Date.now() - startedAt}` + ); } async earthbeamInputForRun(runId: Run['id']) { diff --git a/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts b/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts new file mode 100644 index 00000000..ec7bfb56 --- /dev/null +++ b/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts @@ -0,0 +1,96 @@ +import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import { PRISMA_READ_ONLY } from 'api/src/database'; +import { AppConfigService } from 'api/src/config/app-config.service'; +import type { Connection, Pool } from 'snowflake-sdk'; + +type PoolEntry = { pool: Pool }; + +/** + * Maintains one Snowflake connection pool per partner. Connecting takes ~20s, + * so we warm pools at startup (fire-and-forget) and reuse connections across + * requests. A request for a partner without a pool will create one on demand + * and await its readiness. + */ +@Injectable() +export class EduSnowflakePoolService implements OnModuleInit { + private readonly logger = new Logger(EduSnowflakePoolService.name); + private readonly pools = new Map>(); + + constructor( + @Inject(PRISMA_READ_ONLY) + private readonly prisma: PrismaClient, + private readonly configService: AppConfigService + ) {} + + onModuleInit() { + void this.warmPools(); + } + + /** + * Acquires a connection from the partner's pool, runs the callback, and + * releases the connection. Creates the pool on demand if none exists. + */ + async use(partnerId: string, callback: (connection: Connection) => Promise): Promise { + const entry = await this.getOrCreatePool(partnerId); + return entry.pool.use(callback); + } + + private getOrCreatePool(partnerId: string): Promise { + let entry = this.pools.get(partnerId); + if (!entry) { + entry = this.createPool(partnerId); + this.pools.set(partnerId, entry); + // Evict failed creations so subsequent requests can retry. + entry.catch(() => this.pools.delete(partnerId)); + } + return entry; + } + + private async createPool(partnerId: string): Promise { + const conn = await this.configService.getEduConnectionInfo(partnerId); + if (!conn) { + throw new Error(`No EDU connection info available for partner ${partnerId}`); + } + + // Lazy import: snowflake-sdk has slow module-init side effects we don't + // want to pay on every app boot. + const snowflake = await import('snowflake-sdk'); + + const pool = snowflake.createPool( + { + account: conn.account, + username: conn.username, + database: conn.database, + schema: conn.schema, + authenticator: 'SNOWFLAKE_JWT', + privateKey: conn.privateKey.toString('utf-8'), + }, + { min: 1, max: 4 } + ); + + this.logger.log(`created EDU snowflake pool for partner ${partnerId}`); + return { pool }; + } + + private async warmPools(): Promise { + try { + const partners = await this.prisma.partner.findMany({ + where: { crossYearMatchingEnabled: true }, + select: { id: true }, + }); + if (partners.length === 0) return; + + this.logger.log(`warming EDU snowflake pools for ${partners.length} partner(s)`); + await Promise.allSettled( + partners.map((p) => + this.getOrCreatePool(p.id).catch((err) => + this.logger.warn(`pool warm failed for partner ${p.id}: ${err}`) + ) + ) + ); + } catch (err) { + this.logger.warn(`pool warming aborted: ${err}`); + } + } +} From 19849d89289cb5a1491608782e9ffc6ec01d77ca Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Thu, 14 May 2026 16:56:29 -0500 Subject: [PATCH 16/46] set NODE_ENV=development in EDU env-var tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The env-var fallback in AppConfigService.getEduConnectionInfo is gated on isDevEnvironment() (NODE_ENV === 'development'). Jest defaults NODE_ENV to 'test', so the tests stopped exercising the env-var path once the gate was added — eduCredsExist returned false and the payload omitted the roster URL. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/integration/tests/earthbeam-api.spec.ts | 6 ++++++ app/api/integration/tests/edu-config.spec.ts | 3 +++ 2 files changed, 9 insertions(+) diff --git a/app/api/integration/tests/earthbeam-api.spec.ts b/app/api/integration/tests/earthbeam-api.spec.ts index 60a3791c..95512839 100644 --- a/app/api/integration/tests/earthbeam-api.spec.ts +++ b/app/api/integration/tests/earthbeam-api.spec.ts @@ -83,6 +83,7 @@ describe('Earthbeam API', () => { describe('cross-year ID matching', () => { const EDU_ENV_VARS = [ + 'NODE_ENV', 'EDU_SNOWFLAKE_USERNAME', 'EDU_SNOWFLAKE_ACCOUNT', 'EDU_SNOWFLAKE_DATABASE', @@ -106,6 +107,8 @@ describe('Earthbeam API', () => { savedEnv[key] = process.env[key]; delete process.env[key]; } + // The env-var fallback in AppConfigService is gated on NODE_ENV=development. + process.env.NODE_ENV = 'development'; }); afterEach(() => { @@ -323,6 +326,7 @@ describe('Earthbeam API', () => { let streamSpy: jest.SpyInstance | undefined; const EDU_ENV_VARS = [ + 'NODE_ENV', 'EDU_SNOWFLAKE_USERNAME', 'EDU_SNOWFLAKE_ACCOUNT', 'EDU_SNOWFLAKE_DATABASE', @@ -346,6 +350,8 @@ describe('Earthbeam API', () => { savedEnv[key] = process.env[key]; delete process.env[key]; } + // The env-var fallback in AppConfigService is gated on NODE_ENV=development. + process.env.NODE_ENV = 'development'; streamSpy = undefined; const authService = app.get(EarthbeamApiAuthService); diff --git a/app/api/integration/tests/edu-config.spec.ts b/app/api/integration/tests/edu-config.spec.ts index 52bca772..d4431bda 100644 --- a/app/api/integration/tests/edu-config.spec.ts +++ b/app/api/integration/tests/edu-config.spec.ts @@ -2,6 +2,7 @@ import { AppConfigService } from 'api/src/config/app-config.service'; import { partnerA } from '../fixtures/context-fixtures/partner-fixtures'; const EDU_ENV_VARS = [ + 'NODE_ENV', 'EDU_SNOWFLAKE_USERNAME', 'EDU_SNOWFLAKE_ACCOUNT', 'EDU_SNOWFLAKE_DATABASE', @@ -23,6 +24,8 @@ describe('AppConfigService — EDU Snowflake config', () => { savedEnv[key] = process.env[key]; delete process.env[key]; } + // The env-var fallback in AppConfigService is gated on NODE_ENV=development. + process.env.NODE_ENV = 'development'; }); afterEach(() => { From 728344a7d6a758ad850c0fb869d9dc8f79732280 Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Tue, 19 May 2026 14:49:23 -0500 Subject: [PATCH 17/46] respect backpressure when streaming cross-year roster the previous write loop discarded response.write's return value, which meant a slow executor would cause node to buffer the entire roster in memory. switch to stream.pipeline so node manages backpressure end to end; pipeline also destroys downstream streams on error, preserving the abrupt-close behavior on mid-stream snowflake failures. --- .../earthbeam/api/earthbeam-api.service.ts | 61 ++++++++++--------- 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/app/api/src/earthbeam/api/earthbeam-api.service.ts b/app/api/src/earthbeam/api/earthbeam-api.service.ts index 601b7be7..b59da5bd 100644 --- a/app/api/src/earthbeam/api/earthbeam-api.service.ts +++ b/app/api/src/earthbeam/api/earthbeam-api.service.ts @@ -31,6 +31,8 @@ import { EVENT_EMITTER_SERVICE, } from 'api/src/event-emitter/event-emitter.service'; import type { Response } from 'express'; +import { Transform } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; import { EduSnowflakePoolService } from './edu-snowflake-pool.service'; @Injectable() @@ -52,7 +54,11 @@ export class EarthbeamApiService { include: { job: { include: { tenant: { include: { partner: true } } } } }, }); if (!run) { - return { status: 'ERROR' as const, type: 'not_found' as const, message: `Run not found: ${runId}` }; + return { + status: 'ERROR' as const, + type: 'not_found' as const, + message: `Run not found: ${runId}`, + }; } const partner = run.job.tenant.partner; if (!partner.crossYearMatchingEnabled) { @@ -124,29 +130,23 @@ export class EarthbeamApiService { GROUP BY ALL `; - await new Promise((resolve, reject) => { - const stmt = connection.execute({ - sqlText, - binds: [tenantCode], - streamResult: true, - }); - const rowStream = stmt.streamRows(); - rowStream.on('data', (row) => { - response.write(JSON.stringify(row) + '\n'); - rowCount += 1; - }); - rowStream.on('end', () => { - response.end(); - resolve(); - }); - rowStream.on('error', (err) => { - response.destroy(err); - reject(err); - }); - }); + // pipeline manages backpressure and destroys downstream streams on error + await pipeline( + connection.execute({ sqlText, binds: [tenantCode], streamResult: true }).streamRows(), + new Transform({ + writableObjectMode: true, + transform(row, _enc, cb) { + rowCount += 1; + cb(null, JSON.stringify(row) + '\n'); + }, + }), + response + ); }); this.logger.log( - `cross-year roster: partnerId=${partnerId} tenantCode=${tenantCode} rowCount=${rowCount} durationMs=${Date.now() - startedAt}` + `cross-year roster: partnerId=${partnerId} tenantCode=${tenantCode} rowCount=${rowCount} durationMs=${ + Date.now() - startedAt + }` ); } @@ -281,14 +281,15 @@ export class EarthbeamApiService { ? undefined : `s3://${this.configService.rosterBucket()}/${rosterFileKey(job, job.schoolYear)}`, // odsConnection check narrows the type — the early guard ensures it's present when sendToOds - assessmentDatastore: odsConnection && job.sendToOds - ? { - apiYear: job.schoolYear.endYear.toString(), - url: odsConnection.host, - clientId: odsConnection.clientId, - clientSecret: await this.encryptionService.decrypt(odsConnection.clientSecret), - } - : undefined, + assessmentDatastore: + odsConnection && job.sendToOds + ? { + apiYear: job.schoolYear.endYear.toString(), + url: odsConnection.host, + clientId: odsConnection.clientId, + clientSecret: await this.encryptionService.decrypt(odsConnection.clientSecret), + } + : undefined, }; return { status: 'SUCCESS', From 060a94d8f39efdb1b2e2c40e5f0569a85a42da42 Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Tue, 19 May 2026 17:15:08 -0500 Subject: [PATCH 18/46] distinguish missing EDU secret from AWS failure getEduConnectionInfo previously collapsed every secrets-manager error (IAM, throttling, network, malformed JSON, missing) to null, which collapsed to a 409 from the roster endpoint and crossYearMatchAvailable false in the job payload. operationally indistinguishable from "feature off." now it returns null only for ResourceNotFoundException and logs + rethrows everything else. eduCredsExist (used at payload assembly time) still swallows so unrelated run creation degrades gracefully. the roster endpoint's prereq check switches to calling getEduConnectionInfo directly so a real aws outage produces a 5xx, not 409. --- app/api/src/config/app-config.service.ts | 63 ++++++++++++++----- .../earthbeam/api/earthbeam-api.service.ts | 4 +- 2 files changed, 49 insertions(+), 18 deletions(-) diff --git a/app/api/src/config/app-config.service.ts b/app/api/src/config/app-config.service.ts index 388c91cc..8fd57f32 100644 --- a/app/api/src/config/app-config.service.ts +++ b/app/api/src/config/app-config.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PoolConfig } from 'pg'; import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager'; @@ -35,6 +35,7 @@ export type EduConnectionInfo = { @Injectable() export class AppConfigService { + private readonly logger = new Logger(AppConfigService.name); constructor(private readonly configService: ConfigService) {} get(key: K): IEnvironmentVariables[K] | undefined { @@ -117,30 +118,58 @@ export class AppConfigService { } const secretName = `edu-connection-info-${partnerId}`; + let secret: string | Record; try { - const secret = await this.getAWSSecret(secretName); - if (typeof secret !== 'object') { + secret = await this.getAWSSecret(secretName); + } catch (err) { + // Missing secret → feature off for this partner; any other AWS failure + // (IAM denied, throttled, network, malformed JSON) is operationally + // distinct and must propagate so the roster endpoint can surface a 5xx + // rather than masquerading as 409 "creds missing". + if (err instanceof Error && err.name === 'ResourceNotFoundException') { return null; } - const { username, account, database, schema, publicKey, privateKey } = secret; - if (!username || !account || !database || !schema || !publicKey || !privateKey) { - return null; - } - return { - username, - account, - database, - schema, - publicKey: Buffer.from(publicKey, 'base64'), - privateKey: Buffer.from(privateKey, 'base64'), - }; - } catch { + this.logger.warn( + `failed to load EDU connection info for partner ${partnerId}: ${ + err instanceof Error ? `${err.name}: ${err.message}` : String(err) + }` + ); + throw err; + } + if (typeof secret !== 'object') { + return null; + } + const { username, account, database, schema, publicKey, privateKey } = secret; + if (!username || !account || !database || !schema || !publicKey || !privateKey) { return null; } + return { + username, + account, + database, + schema, + publicKey: Buffer.from(publicKey, 'base64'), + privateKey: Buffer.from(privateKey, 'base64'), + }; } + /** + * Cheap existence check used at payload-assembly time. Degrades gracefully + * on AWS failure (logs + returns false) so a transient Secrets Manager + * issue doesn't break unrelated run creation. The roster endpoint calls + * `getEduConnectionInfo` directly and lets errors propagate. + */ async eduCredsExist(partnerId: string): Promise { - return (await this.getEduConnectionInfo(partnerId)) !== null; + try { + return (await this.getEduConnectionInfo(partnerId)) !== null; + } catch (err) { + this.logger.warn( + `eduCredsExist degrading to false for partner ${partnerId}: ${ + err instanceof Error ? `${err.name}: ${err.message}` : String(err) + }` + ); + return false; + } } bundleBranch(): string { diff --git a/app/api/src/earthbeam/api/earthbeam-api.service.ts b/app/api/src/earthbeam/api/earthbeam-api.service.ts index b59da5bd..cf83e177 100644 --- a/app/api/src/earthbeam/api/earthbeam-api.service.ts +++ b/app/api/src/earthbeam/api/earthbeam-api.service.ts @@ -68,7 +68,9 @@ export class EarthbeamApiService { message: 'Cross-year matching is not enabled for this partner', }; } - if (!(await this.configService.eduCredsExist(partner.id))) { + // Call getEduConnectionInfo directly (rather than eduCredsExist) so a real + // AWS failure surfaces as a 5xx; only a missing secret should produce 409. + if ((await this.configService.getEduConnectionInfo(partner.id)) === null) { return { status: 'ERROR' as const, type: 'conflict' as const, From 03b633a21e282c9f24f031f6b80112ff2db7883b Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Wed, 20 May 2026 09:26:29 -0500 Subject: [PATCH 19/46] test cross-year roster streaming against real code the two streaming tests used to mock streamCrossYearRoster itself, so they never exercised the pipeline, transform, sql, or binds. they now spy on EduSnowflakePoolService.use and supply a fake connection whose execute() returns a Readable, letting the real service body run. happy path asserts on the captured execute args (sqlText targets the EDU staging tables, binds is [tenantCode], streamResult is true) plus the NDJSON body. mid-stream-error asserts the response truncated after exactly one row with no in-band sentinel, replacing a test that asserted nothing. --- .../integration/tests/earthbeam-api.spec.ts | 99 ++++++++++++------- 1 file changed, 66 insertions(+), 33 deletions(-) diff --git a/app/api/integration/tests/earthbeam-api.spec.ts b/app/api/integration/tests/earthbeam-api.spec.ts index 95512839..4a5e0c58 100644 --- a/app/api/integration/tests/earthbeam-api.spec.ts +++ b/app/api/integration/tests/earthbeam-api.spec.ts @@ -1,5 +1,6 @@ import { EarthbeamApiAuthService } from 'api/src/earthbeam/api/auth/earthbeam-api-auth.service'; -import { EarthbeamApiService } from 'api/src/earthbeam/api/earthbeam-api.service'; +import { EduSnowflakePoolService } from 'api/src/earthbeam/api/edu-snowflake-pool.service'; +import { Readable } from 'node:stream'; import request from 'supertest'; import { seedJob } from '../factories/job-factory'; import { bundleA, bundleX } from '../fixtures/em-bundle-fixtures'; @@ -323,7 +324,7 @@ describe('Earthbeam API', () => { let runA: Run; let endpointA: string; let tokenA: string; - let streamSpy: jest.SpyInstance | undefined; + let poolUseSpy: jest.SpyInstance | undefined; const EDU_ENV_VARS = [ 'NODE_ENV', @@ -352,7 +353,7 @@ describe('Earthbeam API', () => { } // The env-var fallback in AppConfigService is gated on NODE_ENV=development. process.env.NODE_ENV = 'development'; - streamSpy = undefined; + poolUseSpy = undefined; const authService = app.get(EarthbeamApiAuthService); const jobA = await seedJob({ @@ -373,7 +374,7 @@ describe('Earthbeam API', () => { process.env[key] = savedEnv[key]; } } - streamSpy?.mockRestore(); + poolUseSpy?.mockRestore(); }); it('rejects unauthenticated requests', async () => { @@ -402,27 +403,31 @@ describe('Earthbeam API', () => { expect(res.status).toBe(409); }); - it('streams NDJSON rows when toggle on and creds present', async () => { + it('streams NDJSON rows from the real streamCrossYearRoster, binding tenant.code', async () => { await global.prisma.partner.update({ where: { id: partnerA.id }, data: { crossYearMatchingEnabled: true }, }); setEduEnvVars(); - const earthbeamApiService = app.get(EarthbeamApiService); const rows = [ { studentUniqueId: '1', priorYear: 2024 }, { studentUniqueId: '2', priorYear: 2024 }, { studentUniqueId: '3', priorYear: 2024 }, ]; - streamSpy = jest - .spyOn(earthbeamApiService, 'streamCrossYearRoster') - .mockImplementation(async ({ response }) => { - for (const row of rows) { - response.write(JSON.stringify(row) + '\n'); - } - response.end(); - }); + let capturedExecute: { sqlText: string; binds: unknown[]; streamResult: boolean } | undefined; + const eduPool = app.get(EduSnowflakePoolService); + // Mock at the pool boundary so the real streamCrossYearRoster body runs + // (pipeline + Transform + the real SQL + binds). + poolUseSpy = jest.spyOn(eduPool, 'use').mockImplementation(async (_partnerId, cb) => { + const fakeConnection = { + execute: (args: { sqlText: string; binds: unknown[]; streamResult: boolean }) => { + capturedExecute = args; + return { streamRows: () => Readable.from(rows) }; + }, + }; + return cb(fakeConnection as never); + }); const res = await request(app.getHttpServer()) .get(endpointA) @@ -435,35 +440,63 @@ describe('Earthbeam API', () => { expect(JSON.parse(lines[0])).toEqual(rows[0]); expect(JSON.parse(lines[2])).toEqual(rows[2]); - expect(streamSpy).toHaveBeenCalledWith( - expect.objectContaining({ - partnerId: partnerA.id, - tenantCode: tenantA.code, - }) + expect(poolUseSpy).toHaveBeenCalledWith(partnerA.id, expect.any(Function)); + expect(capturedExecute).toBeDefined(); + expect(capturedExecute!.binds).toEqual([tenantA.code]); + expect(capturedExecute!.streamResult).toBe(true); + // Sanity-check that the query targets the EDU staging tables and uses the :1 bind. + expect(capturedExecute!.sqlText).toMatch( + /stg_ef3__student_education_organization_associations/ ); + expect(capturedExecute!.sqlText).toMatch(/seoa\.tenant_code\s*=\s*:1/); }); - it('closes the response abruptly when streaming errors mid-flight', async () => { + it('closes the response abruptly when the Snowflake row stream errors mid-flight', async () => { await global.prisma.partner.update({ where: { id: partnerA.id }, data: { crossYearMatchingEnabled: true }, }); setEduEnvVars(); - const earthbeamApiService = app.get(EarthbeamApiService); - streamSpy = jest - .spyOn(earthbeamApiService, 'streamCrossYearRoster') - .mockImplementation(async ({ response }) => { - response.write(JSON.stringify({ studentUniqueId: '1' }) + '\n'); - response.destroy(new Error('snowflake exploded')); - }); + const eduPool = app.get(EduSnowflakePoolService); + poolUseSpy = jest.spyOn(eduPool, 'use').mockImplementation(async (_partnerId, cb) => { + const errorStream = Readable.from( + (async function* () { + yield { studentUniqueId: '1' }; + throw new Error('snowflake exploded mid-stream'); + })() + ); + const fakeConnection = { + execute: () => ({ streamRows: () => errorStream }), + }; + return cb(fakeConnection as never); + }); - // supertest surfaces a destroyed socket as an error; the response should - // not contain a sentinel error line — we just abort. - await request(app.getHttpServer()) - .get(endpointA) - .set('Authorization', `Bearer ${tokenA}`) - .catch((err) => err); // socket close raises; we don't care about the shape + // Abrupt close: pipeline destroys the response on stream error. Headers + // (status + content-type) were already sent, so supertest receives a + // truncated body containing the rows written before the error. + let res: request.Response | undefined; + let sockErr: Error | undefined; + try { + res = await request(app.getHttpServer()) + .get(endpointA) + .set('Authorization', `Bearer ${tokenA}`); + } catch (err) { + sockErr = err as Error; + } + + if (res) { + expect(res.status).toBe(200); + const lines = res.text.split('\n').filter((l) => l.length > 0); + expect(lines).toEqual([JSON.stringify({ studentUniqueId: '1' })]); + // No in-band sentinel / error marker — abrupt close, body simply truncates. + expect(res.text).not.toMatch(/error|exception/i); + } else { + // Some Node/supertest combinations surface the destroyed socket as a + // client-side error instead of a partial body. Either is consistent + // with "abrupt close, no sentinel." + expect(sockErr).toBeDefined(); + } }); }); From a280385d1934eba28597128f9d59d4994ea4da25 Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Wed, 20 May 2026 09:59:16 -0500 Subject: [PATCH 20/46] guard EDU pool eviction against concurrent inserts the catch handler unconditionally deleted the partner key, which could clobber a newly-inserted (in-flight) pool entry under the same key. only delete if the map still points at the failed entry. --- .../earthbeam/api/edu-snowflake-pool.service.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts b/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts index ec7bfb56..750d5ee6 100644 --- a/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts +++ b/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts @@ -41,8 +41,15 @@ export class EduSnowflakePoolService implements OnModuleInit { if (!entry) { entry = this.createPool(partnerId); this.pools.set(partnerId, entry); - // Evict failed creations so subsequent requests can retry. - entry.catch(() => this.pools.delete(partnerId)); + // Evict failed creations so subsequent requests can retry. Guard against + // racing with a concurrent insertion under the same key — only delete if + // the map still points at this failed entry. + const failedEntry = entry; + entry.catch(() => { + if (this.pools.get(partnerId) === failedEntry) { + this.pools.delete(partnerId); + } + }); } return entry; } @@ -57,6 +64,10 @@ export class EduSnowflakePoolService implements OnModuleInit { // want to pay on every app boot. const snowflake = await import('snowflake-sdk'); + // The SDK writes a `snowflake.log` file to CWD by default. Silence it; + // we surface failures through Nest's logger instead. + snowflake.configure({ logLevel: 'OFF' }); + const pool = snowflake.createPool( { account: conn.account, From 123bdc647f41b1add44b1776bdb54f344d655b63 Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Wed, 20 May 2026 10:28:36 -0500 Subject: [PATCH 21/46] drop unused EDU publicKey from connection info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit snowflake key-pair auth uses only the private key; the public half is uploaded to snowflake out-of-band. carrying publicKey through the config layer added nothing and the "missing publicKey → null" gate would have silently disabled the feature for an otherwise-valid secret. removed from the EduConnectionInfo type, both secret and env-var fallbacks, the validation check, .env.copyme, and the test fixtures. --- app/api/.env.copyme | 9 ++++----- app/api/integration/tests/earthbeam-api.spec.ts | 4 ---- app/api/integration/tests/edu-config.spec.ts | 5 ----- app/api/src/config/app-config.service.ts | 10 +++------- 4 files changed, 7 insertions(+), 21 deletions(-) diff --git a/app/api/.env.copyme b/app/api/.env.copyme index c4b957bc..c6e24036 100644 --- a/app/api/.env.copyme +++ b/app/api/.env.copyme @@ -27,15 +27,14 @@ LOCAL_EVENT_EMITTER=log # log | noop (omit for EventBridge) AWS_REGION=us-east-2 BUNDLE_BRANCH=development -# EDU Snowflake cross-year roster fallback for local development only -# Public/private key values should be base64-encoded PEM contents. -# Example from files: `base64 -w 0 public_key.pem` and `base64 -w 0 private_key.pem` -# Example from clipboard on Linux: `xclip -selection clipboard -o | base64 -w 0` +# EDU Snowflake cross-year roster fallback for local development only. +# Private key value should be base64-encoded PEM contents. +# Example from file: `base64 -w 0 private_key.pem` +# Example from clipboard: `xclip -selection clipboard -o | base64 -w 0` EDU_SNOWFLAKE_USERNAME= EDU_SNOWFLAKE_ACCOUNT= EDU_SNOWFLAKE_DATABASE= EDU_SNOWFLAKE_SCHEMA= -EDU_SNOWFLAKE_PUBLIC_KEY= EDU_SNOWFLAKE_PRIVATE_KEY= OAUTH2_ISSUER=http://localhost:8080/realms/example diff --git a/app/api/integration/tests/earthbeam-api.spec.ts b/app/api/integration/tests/earthbeam-api.spec.ts index 4a5e0c58..87729fcd 100644 --- a/app/api/integration/tests/earthbeam-api.spec.ts +++ b/app/api/integration/tests/earthbeam-api.spec.ts @@ -89,7 +89,6 @@ describe('Earthbeam API', () => { 'EDU_SNOWFLAKE_ACCOUNT', 'EDU_SNOWFLAKE_DATABASE', 'EDU_SNOWFLAKE_SCHEMA', - 'EDU_SNOWFLAKE_PUBLIC_KEY', 'EDU_SNOWFLAKE_PRIVATE_KEY', ] as const; const savedEnv: Record = {}; @@ -99,7 +98,6 @@ describe('Earthbeam API', () => { process.env.EDU_SNOWFLAKE_ACCOUNT = 'example'; process.env.EDU_SNOWFLAKE_DATABASE = 'edu_stg'; process.env.EDU_SNOWFLAKE_SCHEMA = 'public'; - process.env.EDU_SNOWFLAKE_PUBLIC_KEY = Buffer.from('pub').toString('base64'); process.env.EDU_SNOWFLAKE_PRIVATE_KEY = Buffer.from('priv').toString('base64'); }; @@ -332,7 +330,6 @@ describe('Earthbeam API', () => { 'EDU_SNOWFLAKE_ACCOUNT', 'EDU_SNOWFLAKE_DATABASE', 'EDU_SNOWFLAKE_SCHEMA', - 'EDU_SNOWFLAKE_PUBLIC_KEY', 'EDU_SNOWFLAKE_PRIVATE_KEY', ] as const; const savedEnv: Record = {}; @@ -342,7 +339,6 @@ describe('Earthbeam API', () => { process.env.EDU_SNOWFLAKE_ACCOUNT = 'example'; process.env.EDU_SNOWFLAKE_DATABASE = 'edu_stg'; process.env.EDU_SNOWFLAKE_SCHEMA = 'public'; - process.env.EDU_SNOWFLAKE_PUBLIC_KEY = Buffer.from('pub').toString('base64'); process.env.EDU_SNOWFLAKE_PRIVATE_KEY = Buffer.from('priv').toString('base64'); }; diff --git a/app/api/integration/tests/edu-config.spec.ts b/app/api/integration/tests/edu-config.spec.ts index d4431bda..de7f735b 100644 --- a/app/api/integration/tests/edu-config.spec.ts +++ b/app/api/integration/tests/edu-config.spec.ts @@ -7,7 +7,6 @@ const EDU_ENV_VARS = [ 'EDU_SNOWFLAKE_ACCOUNT', 'EDU_SNOWFLAKE_DATABASE', 'EDU_SNOWFLAKE_SCHEMA', - 'EDU_SNOWFLAKE_PUBLIC_KEY', 'EDU_SNOWFLAKE_PRIVATE_KEY', ] as const; @@ -49,7 +48,6 @@ describe('AppConfigService — EDU Snowflake config', () => { process.env.EDU_SNOWFLAKE_ACCOUNT = 'example'; process.env.EDU_SNOWFLAKE_DATABASE = 'edu_stg'; process.env.EDU_SNOWFLAKE_SCHEMA = 'public'; - process.env.EDU_SNOWFLAKE_PUBLIC_KEY = Buffer.from('public-key').toString('base64'); process.env.EDU_SNOWFLAKE_PRIVATE_KEY = Buffer.from('private-key').toString('base64'); const exists = await configService.eduCredsExist(partnerA.id); @@ -65,12 +63,10 @@ describe('AppConfigService — EDU Snowflake config', () => { it('returns a connection info object built from env vars when set', async () => { const privateKey = Buffer.from('private-key-content').toString('base64'); - const publicKey = Buffer.from('public-key-content').toString('base64'); process.env.EDU_SNOWFLAKE_USERNAME = 'snowflake-user'; process.env.EDU_SNOWFLAKE_ACCOUNT = 'example'; process.env.EDU_SNOWFLAKE_DATABASE = 'edu_stg'; process.env.EDU_SNOWFLAKE_SCHEMA = 'public'; - process.env.EDU_SNOWFLAKE_PUBLIC_KEY = publicKey; process.env.EDU_SNOWFLAKE_PRIVATE_KEY = privateKey; const info = await configService.getEduConnectionInfo(partnerA.id); @@ -79,7 +75,6 @@ describe('AppConfigService — EDU Snowflake config', () => { account: 'example', database: 'edu_stg', schema: 'public', - publicKey: Buffer.from('public-key-content'), privateKey: Buffer.from('private-key-content'), }); }); diff --git a/app/api/src/config/app-config.service.ts b/app/api/src/config/app-config.service.ts index 8fd57f32..f238ffb8 100644 --- a/app/api/src/config/app-config.service.ts +++ b/app/api/src/config/app-config.service.ts @@ -13,7 +13,6 @@ export type EduConnectionInfo = { account: string; database: string; schema: string; - publicKey: Buffer; privateKey: Buffer; }; @@ -139,8 +138,8 @@ export class AppConfigService { if (typeof secret !== 'object') { return null; } - const { username, account, database, schema, publicKey, privateKey } = secret; - if (!username || !account || !database || !schema || !publicKey || !privateKey) { + const { username, account, database, schema, privateKey } = secret; + if (!username || !account || !database || !schema || !privateKey) { return null; } return { @@ -148,7 +147,6 @@ export class AppConfigService { account, database, schema, - publicKey: Buffer.from(publicKey, 'base64'), privateKey: Buffer.from(privateKey, 'base64'), }; } @@ -280,9 +278,8 @@ export class AppConfigService { const account = process.env.EDU_SNOWFLAKE_ACCOUNT; const database = process.env.EDU_SNOWFLAKE_DATABASE; const schema = process.env.EDU_SNOWFLAKE_SCHEMA; - const publicKeyB64 = process.env.EDU_SNOWFLAKE_PUBLIC_KEY; const privateKeyB64 = process.env.EDU_SNOWFLAKE_PRIVATE_KEY; - if (!envUsername || !account || !database || !schema || !publicKeyB64 || !privateKeyB64) { + if (!envUsername || !account || !database || !schema || !privateKeyB64) { return null; } @@ -291,7 +288,6 @@ export class AppConfigService { account, database, schema, - publicKey: Buffer.from(publicKeyB64, 'base64'), privateKey: Buffer.from(privateKeyB64, 'base64'), }; } From 52999d3558ab506b554dcd7dd0fe4d8ac861ec94 Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Wed, 20 May 2026 10:31:25 -0500 Subject: [PATCH 22/46] document cross-year roster fetch in AGENTS.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the executor's conditional roster-fetch step was missing from the Executor Lifecycle section. also add the DTO file to the App↔Executor key files. project policy in AGENTS.md itself requires doc updates alongside behavior changes. --- AGENTS.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index db97a092..6ad0d0a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -145,6 +145,7 @@ sequenceDiagram - `app/api/src/earthbeam/api/earthbeam-api.controller.ts` — HTTP callback endpoints the executor calls - `app/api/src/earthbeam/api/earthbeam-api.service.ts` — Job payload assembly, run completion +- `app/models/src/dtos/earthbeam-api.dto.ts` — Job payload shape - `executor/executor/executor.py` — Main executor: S3 operations, HTTP callbacks, earthmover/lightbeam invocation ### Executor Lifecycle @@ -152,7 +153,7 @@ sequenceDiagram 1. **Init**: GET `INIT_JOB_URL` with `INIT_TOKEN` → receives auth token + job URL 2. **Job fetch**: GET job URL → full job definition (files, ODS creds, bundle, callback URLs) 3. **Bundle refresh**: git fetch/checkout/pull the earthmover bundle -4. **Roster fetch**: `lightbeam fetch` student roster from ODS, upload artifact to S3 +4. **Roster fetch**: `lightbeam fetch` student roster from ODS, upload artifact to S3. When the job payload's `crossYearMatchAvailable` flag is true, the executor (in a follow-up repo PR) also calls `appUrls.roster` and writes the streamed NDJSON roster to a `.jsonl` file for the earthmover transform to consume. 5. **File download**: Download user-uploaded input files from S3 6. **Transform**: `earthmover run` (with encoding detection + retry) 7. **Load**: `lightbeam send` to Ed-Fi ODS From 598a9453f9d51f939044f7ce0944ab7a9738c2ca Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Wed, 20 May 2026 10:32:58 -0500 Subject: [PATCH 23/46] cap EDU pool acquire wait at 60s generic-pool defaults acquireTimeoutMillis to forever, so under saturation (5+ concurrent runs for the same partner) requests could hang indefinitely. 60s is generous over the ~20s JWT connect observed locally and gives a clear timeout error instead of an unbounded wait. --- app/api/src/earthbeam/api/edu-snowflake-pool.service.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts b/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts index 750d5ee6..6c7d5504 100644 --- a/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts +++ b/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts @@ -77,7 +77,11 @@ export class EduSnowflakePoolService implements OnModuleInit { authenticator: 'SNOWFLAKE_JWT', privateKey: conn.privateKey.toString('utf-8'), }, - { min: 1, max: 4 } + // Cap acquire waits so a saturated pool surfaces a clear timeout error + // instead of hanging the request forever (generic-pool's default). 60s + // is intentionally generous — JWT connect alone is ~20s, so this needs + // headroom for cold-start + queue wait in a bursty 5+ concurrent run. + { min: 1, max: 4, acquireTimeoutMillis: 60_000 } ); this.logger.log(`created EDU snowflake pool for partner ${partnerId}`); From fd88f5aa69f9b8589c4460c927e72786a9145c5e Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Wed, 20 May 2026 10:34:39 -0500 Subject: [PATCH 24/46] drain EDU snowflake pools on shutdown without onModuleDestroy, open snowflake sockets outlived the process on SIGTERM (beanstalk/ECS deploys), risking hung shutdowns past the platform grace period. drains each pool with a 10s race timeout under Promise.allSettled so one stuck pool can't block the others. --- .../api/edu-snowflake-pool.service.ts | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts b/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts index 6c7d5504..f20b7bda 100644 --- a/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts +++ b/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Inject, Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; import { PRISMA_READ_ONLY } from 'api/src/database'; import { AppConfigService } from 'api/src/config/app-config.service'; @@ -13,7 +13,7 @@ type PoolEntry = { pool: Pool }; * and await its readiness. */ @Injectable() -export class EduSnowflakePoolService implements OnModuleInit { +export class EduSnowflakePoolService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(EduSnowflakePoolService.name); private readonly pools = new Map>(); @@ -27,6 +27,38 @@ export class EduSnowflakePoolService implements OnModuleInit { void this.warmPools(); } + /** + * Drain and clear every pool on shutdown so Snowflake sockets don't outlive + * the process during deploys (Beanstalk/ECS SIGTERM). Bounded per-pool to + * stay well under the platform grace period. + */ + async onModuleDestroy(): Promise { + const entries = Array.from(this.pools.entries()); + this.pools.clear(); + if (entries.length === 0) return; + + this.logger.log(`shutting down ${entries.length} EDU snowflake pool(s)`); + const shutdownTimeoutMs = 10_000; + await Promise.allSettled( + entries.map(async ([partnerId, entryPromise]) => { + try { + const { pool } = await entryPromise; + await Promise.race([ + pool.drain().then(() => pool.clear()), + new Promise((_, reject) => + setTimeout( + () => reject(new Error(`drain timed out after ${shutdownTimeoutMs}ms`)), + shutdownTimeoutMs + ) + ), + ]); + } catch (err) { + this.logger.warn(`pool shutdown failed for partner ${partnerId}: ${err}`); + } + }) + ); + } + /** * Acquires a connection from the partner's pool, runs the callback, and * releases the connection. Creates the pool on demand if none exists. From 710f083953748ff2306b76ee1aa776f31ca62833 Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Wed, 20 May 2026 10:47:36 -0500 Subject: [PATCH 25/46] return 500 for pre-stream roster failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the abrupt-close decision was for mid-stream failures (after rows have been written). when pool acquisition or connection.execute fails before the first row, no bytes are flushed yet — tearing the TCP connection just makes it harder for the executor to diagnose. the controller now checks res.headersSent and throws InternalServerErrorException when nothing has been sent, falling back to res.destroy only after headers are out. test covers the pre-stream case. --- .../integration/tests/earthbeam-api.spec.ts | 21 +++++++++++++++++++ .../earthbeam/api/earthbeam-api.controller.ts | 14 +++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/app/api/integration/tests/earthbeam-api.spec.ts b/app/api/integration/tests/earthbeam-api.spec.ts index 87729fcd..9b1ec5a7 100644 --- a/app/api/integration/tests/earthbeam-api.spec.ts +++ b/app/api/integration/tests/earthbeam-api.spec.ts @@ -494,6 +494,27 @@ describe('Earthbeam API', () => { expect(sockErr).toBeDefined(); } }); + + it('returns 500 when pool acquisition fails before any bytes are streamed', async () => { + await global.prisma.partner.update({ + where: { id: partnerA.id }, + data: { crossYearMatchingEnabled: true }, + }); + setEduEnvVars(); + + const eduPool = app.get(EduSnowflakePoolService); + poolUseSpy = jest + .spyOn(eduPool, 'use') + .mockRejectedValue(new Error('pool acquisition failed')); + + const res = await request(app.getHttpServer()) + .get(endpointA) + .set('Authorization', `Bearer ${tokenA}`); + + expect(res.status).toBe(500); + // A clean JSON 500 — easier for the executor to diagnose than a torn socket. + expect(res.headers['content-type']).toContain('application/json'); + }); }); describe('POST /:runId/status', () => { diff --git a/app/api/src/earthbeam/api/earthbeam-api.controller.ts b/app/api/src/earthbeam/api/earthbeam-api.controller.ts index 4ba66dbb..d0b8cde3 100644 --- a/app/api/src/earthbeam/api/earthbeam-api.controller.ts +++ b/app/api/src/earthbeam/api/earthbeam-api.controller.ts @@ -93,8 +93,18 @@ export class EarthbeamApiController { this.logger.error( `cross-year roster fetch failed for run ${runId}: ${err instanceof Error ? err.message : String(err)}` ); - if (!res.destroyed) { - res.destroy(err instanceof Error ? err : new Error(String(err))); + // Abrupt close was a deliberate choice for *mid-stream* failures. If + // headers haven't been sent yet (pool acquire / execute failed before + // any rows), emit a clean 500 instead — easier for the executor to + // diagnose than a torn TCP connection. + if (res.headersSent) { + if (!res.destroyed) { + res.destroy(err instanceof Error ? err : new Error(String(err))); + } + } else { + throw new InternalServerErrorException( + err instanceof Error ? err.message : String(err) + ); } } } From f1c566ae94567e12d6a4e7f81b94684f7cda12b1 Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Wed, 20 May 2026 10:52:31 -0500 Subject: [PATCH 26/46] destroy EDU connection on callback failure generic-pool's pool.use() releases the resource on both resolve and reject, so a connection whose row stream errored mid-flight gets handed to the next acquirer as healthy. switch to explicit acquire + release on success / destroy on error so the suspect connection leaves the pool. --- .../api/edu-snowflake-pool.service.ts | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts b/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts index f20b7bda..bac009a1 100644 --- a/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts +++ b/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts @@ -61,11 +61,25 @@ export class EduSnowflakePoolService implements OnModuleInit, OnModuleDestroy { /** * Acquires a connection from the partner's pool, runs the callback, and - * releases the connection. Creates the pool on demand if none exists. + * releases the connection on success. On error the connection is destroyed + * rather than released — a callback failure (e.g. a row-stream error) can + * leave the underlying connection in an indeterminate state, and + * generic-pool's own `use()` helper unconditionally releases, handing the + * suspect resource to the next acquirer. */ async use(partnerId: string, callback: (connection: Connection) => Promise): Promise { - const entry = await this.getOrCreatePool(partnerId); - return entry.pool.use(callback); + const { pool } = await this.getOrCreatePool(partnerId); + const connection = await pool.acquire(); + try { + const result = await callback(connection); + await pool.release(connection); + return result; + } catch (err) { + await pool.destroy(connection).catch((destroyErr) => { + this.logger.warn(`pool.destroy failed for partner ${partnerId}: ${destroyErr}`); + }); + throw err; + } } private getOrCreatePool(partnerId: string): Promise { From e81bc8bec8eeea4bd3daa707520dcd3cf10d884c Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Wed, 20 May 2026 11:01:42 -0500 Subject: [PATCH 27/46] Revert "destroy EDU connection on callback failure" This reverts commit f1c566ae94567e12d6a4e7f81b94684f7cda12b1. --- .../api/edu-snowflake-pool.service.ts | 20 +++---------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts b/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts index bac009a1..f20b7bda 100644 --- a/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts +++ b/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts @@ -61,25 +61,11 @@ export class EduSnowflakePoolService implements OnModuleInit, OnModuleDestroy { /** * Acquires a connection from the partner's pool, runs the callback, and - * releases the connection on success. On error the connection is destroyed - * rather than released — a callback failure (e.g. a row-stream error) can - * leave the underlying connection in an indeterminate state, and - * generic-pool's own `use()` helper unconditionally releases, handing the - * suspect resource to the next acquirer. + * releases the connection. Creates the pool on demand if none exists. */ async use(partnerId: string, callback: (connection: Connection) => Promise): Promise { - const { pool } = await this.getOrCreatePool(partnerId); - const connection = await pool.acquire(); - try { - const result = await callback(connection); - await pool.release(connection); - return result; - } catch (err) { - await pool.destroy(connection).catch((destroyErr) => { - this.logger.warn(`pool.destroy failed for partner ${partnerId}: ${destroyErr}`); - }); - throw err; - } + const entry = await this.getOrCreatePool(partnerId); + return entry.pool.use(callback); } private getOrCreatePool(partnerId: string): Promise { From 07ae9d63c8d252075974d0956e978c6f584d1ce4 Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Wed, 20 May 2026 11:39:38 -0500 Subject: [PATCH 28/46] fix indentation of getAWSSecret MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the private async getAWSSecret signature lost its 2-space class-member indent — re-indent to match neighbors. --- app/api/src/config/app-config.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/src/config/app-config.service.ts b/app/api/src/config/app-config.service.ts index f238ffb8..4ccf0620 100644 --- a/app/api/src/config/app-config.service.ts +++ b/app/api/src/config/app-config.service.ts @@ -292,7 +292,7 @@ export class AppConfigService { }; } -private async getAWSSecret(secretName: string): Promise> { + private async getAWSSecret(secretName: string): Promise> { const cachedValue = this.secretsCache.get(secretName); if (cachedValue) { return cachedValue; From 313cbce3db81c8c9118364071357030cb504fc8e Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Wed, 20 May 2026 11:39:38 -0500 Subject: [PATCH 29/46] describe cross-year retry in AGENTS.md executor lifecycle the existing entry only mentioned cross-year as a side note on the ODS roster fetch step, which implied a single transform pass that includes the cross-year roster up front. the actual flow is a second earthmover pass triggered only when the first pass leaves unmatched students and the partner has the feature enabled. --- AGENTS.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6ad0d0a6..4f78f35f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -153,13 +153,14 @@ sequenceDiagram 1. **Init**: GET `INIT_JOB_URL` with `INIT_TOKEN` → receives auth token + job URL 2. **Job fetch**: GET job URL → full job definition (files, ODS creds, bundle, callback URLs) 3. **Bundle refresh**: git fetch/checkout/pull the earthmover bundle -4. **Roster fetch**: `lightbeam fetch` student roster from ODS, upload artifact to S3. When the job payload's `crossYearMatchAvailable` flag is true, the executor (in a follow-up repo PR) also calls `appUrls.roster` and writes the streamed NDJSON roster to a `.jsonl` file for the earthmover transform to consume. +4. **Roster fetch**: `lightbeam fetch` student roster from ODS, upload artifact to S3 5. **File download**: Download user-uploaded input files from S3 -6. **Transform**: `earthmover run` (with encoding detection + retry) -7. **Load**: `lightbeam send` to Ed-Fi ODS -8. **Report**: POST summary, unmatched IDs, errors to app via callback URLs -9. **Output files**: POST output file path + `sentToOds` flag to `/output-files` callback; app validates path, lists S3, saves `run_output_file_set` -10. **Done**: POST status `{action: DONE, status: success|failure}` +6. **Transform**: `earthmover run` against the ODS roster (with encoding detection + retry) +7. **Cross-year retry** (when `crossYearMatchAvailable` and the first pass produced unmatched students): GET `appUrls.roster` for the cross-year NDJSON roster, write to a `.jsonl` file, and re-run `earthmover` against it using the same ID type. Recovers students whose identifiers changed between school years. +8. **Load**: `lightbeam send` to Ed-Fi ODS +9. **Report**: POST summary, unmatched IDs, errors to app via callback URLs +10. **Output files**: POST output file path + `sentToOds` flag to `/output-files` callback; app validates path, lists S3, saves `run_output_file_set` +11. **Done**: POST status `{action: DONE, status: success|failure}` ### S3 Path Structure From 80425fc1e49897710e9a77c7bb7bb3f0b90c31ed Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Wed, 20 May 2026 11:50:44 -0500 Subject: [PATCH 30/46] inline eduCredsExist into its one caller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit eduCredsExist was a thin wrapper that called getEduConnectionInfo, did !== null, and swallowed errors as "false". the swallow-on-error decision is specific to job-payload assembly (don't break run creation if Secrets Manager hiccups) — moving it to that one caller keeps app-config focused on fetching creds rather than also owning what to do when fetching fails. drops the wrapper and its two thin tests; the real fetch is still covered by the getEduConnectionInfo tests and by the cross-year payload tests in earthbeam-api.spec.ts. --- app/api/integration/tests/edu-config.spec.ts | 18 ----------------- app/api/src/config/app-config.service.ts | 19 ------------------ .../earthbeam/api/earthbeam-api.service.ts | 20 ++++++++++++++++--- 3 files changed, 17 insertions(+), 40 deletions(-) diff --git a/app/api/integration/tests/edu-config.spec.ts b/app/api/integration/tests/edu-config.spec.ts index de7f735b..36fb34fb 100644 --- a/app/api/integration/tests/edu-config.spec.ts +++ b/app/api/integration/tests/edu-config.spec.ts @@ -37,24 +37,6 @@ describe('AppConfigService — EDU Snowflake config', () => { } }); - describe('eduCredsExist', () => { - it('returns false when no env vars are set and no AWS secret exists', async () => { - const exists = await configService.eduCredsExist(partnerA.id); - expect(exists).toBe(false); - }); - - it('returns true when local env vars are set', async () => { - process.env.EDU_SNOWFLAKE_USERNAME = 'snowflake-user'; - process.env.EDU_SNOWFLAKE_ACCOUNT = 'example'; - process.env.EDU_SNOWFLAKE_DATABASE = 'edu_stg'; - process.env.EDU_SNOWFLAKE_SCHEMA = 'public'; - process.env.EDU_SNOWFLAKE_PRIVATE_KEY = Buffer.from('private-key').toString('base64'); - - const exists = await configService.eduCredsExist(partnerA.id); - expect(exists).toBe(true); - }); - }); - describe('getEduConnectionInfo', () => { it('returns null when no env vars are set and no AWS secret exists', async () => { const info = await configService.getEduConnectionInfo(partnerA.id); diff --git a/app/api/src/config/app-config.service.ts b/app/api/src/config/app-config.service.ts index 4ccf0620..3d867131 100644 --- a/app/api/src/config/app-config.service.ts +++ b/app/api/src/config/app-config.service.ts @@ -151,25 +151,6 @@ export class AppConfigService { }; } - /** - * Cheap existence check used at payload-assembly time. Degrades gracefully - * on AWS failure (logs + returns false) so a transient Secrets Manager - * issue doesn't break unrelated run creation. The roster endpoint calls - * `getEduConnectionInfo` directly and lets errors propagate. - */ - async eduCredsExist(partnerId: string): Promise { - try { - return (await this.getEduConnectionInfo(partnerId)) !== null; - } catch (err) { - this.logger.warn( - `eduCredsExist degrading to false for partner ${partnerId}: ${ - err instanceof Error ? `${err.name}: ${err.message}` : String(err) - }` - ); - return false; - } - } - bundleBranch(): string { return this.get('BUNDLE_BRANCH') ?? 'main'; } diff --git a/app/api/src/earthbeam/api/earthbeam-api.service.ts b/app/api/src/earthbeam/api/earthbeam-api.service.ts index cf83e177..735c27f5 100644 --- a/app/api/src/earthbeam/api/earthbeam-api.service.ts +++ b/app/api/src/earthbeam/api/earthbeam-api.service.ts @@ -251,9 +251,23 @@ export class EarthbeamApiService { const executorBaseUrl = this.configService.executorCallbackBaseUrl(); const partnerId = job.tenant.partnerId; - const crossYearMatchAvailable = - job.tenant.partner.crossYearMatchingEnabled && - (await this.configService.eduCredsExist(partnerId)); + const crossYearEnabled = job.tenant.partner.crossYearMatchingEnabled; + let eduCredsAvailable = false; + if (crossYearEnabled) { + try { + eduCredsAvailable = (await this.configService.getEduConnectionInfo(partnerId)) !== null; + } catch (err) { + // Degrade gracefully so a transient Secrets Manager failure doesn't + // break unrelated run creation. The roster endpoint calls + // getEduConnectionInfo directly and lets real failures propagate. + this.logger.warn( + `EDU creds lookup failed for partner ${partnerId}: ${ + err instanceof Error ? `${err.name}: ${err.message}` : String(err) + }` + ); + } + } + const crossYearMatchAvailable = crossYearEnabled && eduCredsAvailable; const payload: EarthbeamApiJobResponseDto = { appDataBasePath: `${job.fileProtocol}://${job.fileBucketOrHost}/${job.fileBasePath}`, From b02fa5d453a497057b80a437e08927b964fa8518 Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Wed, 20 May 2026 11:55:23 -0500 Subject: [PATCH 31/46] consolidate EDU connection info into one method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit inline the env-var fallback path (matching how postgresPoolConfig and encryptionKey already handle their AWS/env split) and drop the EduConnectionInfo named type — callers infer the shape. brings the EDU method into line with the rest of AppConfigService. --- app/api/src/config/app-config.service.ts | 56 +++++++++--------------- 1 file changed, 21 insertions(+), 35 deletions(-) diff --git a/app/api/src/config/app-config.service.ts b/app/api/src/config/app-config.service.ts index 3d867131..79f0f5df 100644 --- a/app/api/src/config/app-config.service.ts +++ b/app/api/src/config/app-config.service.ts @@ -8,14 +8,6 @@ import { SSMClient, GetParametersCommand, Parameter } from '@aws-sdk/client-ssm' type ParameterWithNameAndValue = Required>; -export type EduConnectionInfo = { - username: string; - account: string; - database: string; - schema: string; - privateKey: Buffer; -}; - /** * AppConfigService is a wrapper on the @nestjs/config package's * ConfigService. It allows us to define custom getters, including @@ -108,12 +100,29 @@ export class AppConfigService { /** * EDU Snowflake connection info for cross-year ID matching. Looks for an * AWS secret named `edu-connection-info-`; falls back to - * EDU_SNOWFLAKE_* env vars only in local development. - * Returns null when no creds are available — caller decides how to handle. + * EDU_SNOWFLAKE_* env vars only in local development. Returns null when no + * creds are available — caller decides how to handle. Throws on real AWS + * failures (IAM, throttling, network, malformed JSON) so the roster + * endpoint can surface a 5xx rather than masquerading as 409 "creds + * missing"; only ResourceNotFoundException collapses to null. */ - async getEduConnectionInfo(partnerId: string): Promise { + async getEduConnectionInfo(partnerId: string) { if (this.isDevEnvironment()) { - return this.getEduConnectionInfoFromEnv(); + const username = process.env.EDU_SNOWFLAKE_USERNAME; + const account = process.env.EDU_SNOWFLAKE_ACCOUNT; + const database = process.env.EDU_SNOWFLAKE_DATABASE; + const schema = process.env.EDU_SNOWFLAKE_SCHEMA; + const privateKey = process.env.EDU_SNOWFLAKE_PRIVATE_KEY; + if (!username || !account || !database || !schema || !privateKey) { + return null; + } + return { + username, + account, + database, + schema, + privateKey: Buffer.from(privateKey, 'base64'), + }; } const secretName = `edu-connection-info-${partnerId}`; @@ -121,10 +130,6 @@ export class AppConfigService { try { secret = await this.getAWSSecret(secretName); } catch (err) { - // Missing secret → feature off for this partner; any other AWS failure - // (IAM denied, throttled, network, malformed JSON) is operationally - // distinct and must propagate so the roster endpoint can surface a 5xx - // rather than masquerading as 409 "creds missing". if (err instanceof Error && err.name === 'ResourceNotFoundException') { return null; } @@ -254,25 +259,6 @@ export class AppConfigService { private readonly secretsCache: Map> = new Map(); - private getEduConnectionInfoFromEnv(): EduConnectionInfo | null { - const envUsername = process.env.EDU_SNOWFLAKE_USERNAME; - const account = process.env.EDU_SNOWFLAKE_ACCOUNT; - const database = process.env.EDU_SNOWFLAKE_DATABASE; - const schema = process.env.EDU_SNOWFLAKE_SCHEMA; - const privateKeyB64 = process.env.EDU_SNOWFLAKE_PRIVATE_KEY; - if (!envUsername || !account || !database || !schema || !privateKeyB64) { - return null; - } - - return { - username: envUsername, - account, - database, - schema, - privateKey: Buffer.from(privateKeyB64, 'base64'), - }; - } - private async getAWSSecret(secretName: string): Promise> { const cachedValue = this.secretsCache.get(secretName); if (cachedValue) { From 60b6fd5f9d7bd359643f7eb41ed88250cd123dce Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Wed, 20 May 2026 12:41:12 -0500 Subject: [PATCH 32/46] move EDU creds existence check into the pool service motivation: - the central secrets cache in AppConfigService meant cred rotation required a process restart on top of the pool refresh PR 2 already plans. - "do creds exist?" was answered by AppConfigService, which had no natural way to bypass its own cache for the EDU path. - payload assembly and the roster endpoint were both asking AppConfig about EDU, even though the pool service is the thing that actually uses the creds. changes: - AppConfigService.getAWSSecret now wraps a new uncached fetchAWSSecret; postgres/encryption/jwt callers keep cached semantics, EDU calls fetchAWSSecret directly. one cache for one concern. - EduSnowflakePoolService gains a public canConnect(partnerId) that answers from a resolvedPools set when a pool is already up (no AWS call needed) or falls back to a fresh getEduConnectionInfo lookup. failures are swallowed + logged so payload assembly can degrade. - earthbeamInputForRun and getCrossYearRosterContext both ask the pool service instead of AppConfig. the 5xx-vs-409 distinction at the roster endpoint becomes 409 for any "can't connect" reason; real failures during streaming still propagate through the controller's headersSent-aware catch (500 pre-stream, abrupt close mid-stream). --- app/api/src/config/app-config.service.ts | 11 ++++++-- .../earthbeam/api/earthbeam-api.service.ts | 21 ++------------- .../api/edu-snowflake-pool.service.ts | 27 +++++++++++++++++++ 3 files changed, 38 insertions(+), 21 deletions(-) diff --git a/app/api/src/config/app-config.service.ts b/app/api/src/config/app-config.service.ts index 79f0f5df..644e5bc7 100644 --- a/app/api/src/config/app-config.service.ts +++ b/app/api/src/config/app-config.service.ts @@ -128,7 +128,10 @@ export class AppConfigService { const secretName = `edu-connection-info-${partnerId}`; let secret: string | Record; try { - secret = await this.getAWSSecret(secretName); + // Uncached: cred-rotation handling lives in EduSnowflakePoolService, + // and the existence check there does a fresh fetch each time so a + // process restart isn't required to pick up new values. + secret = await this.fetchAWSSecret(secretName); } catch (err) { if (err instanceof Error && err.name === 'ResourceNotFoundException') { return null; @@ -264,7 +267,12 @@ export class AppConfigService { if (cachedValue) { return cachedValue; } + const secretValue = await this.fetchAWSSecret(secretName); + this.secretsCache.set(secretName, secretValue); + return secretValue; + } + private async fetchAWSSecret(secretName: string): Promise> { const secretValueRaw = await this.secretsClient.send( new GetSecretValueCommand({ SecretId: secretName }) ); @@ -284,7 +292,6 @@ export class AppConfigService { throw new Error(`Unable to parse value for secret ${secretName}`); } - this.secretsCache.set(secretName, secretValue); return secretValue; } diff --git a/app/api/src/earthbeam/api/earthbeam-api.service.ts b/app/api/src/earthbeam/api/earthbeam-api.service.ts index 735c27f5..a1a09e1c 100644 --- a/app/api/src/earthbeam/api/earthbeam-api.service.ts +++ b/app/api/src/earthbeam/api/earthbeam-api.service.ts @@ -68,9 +68,7 @@ export class EarthbeamApiService { message: 'Cross-year matching is not enabled for this partner', }; } - // Call getEduConnectionInfo directly (rather than eduCredsExist) so a real - // AWS failure surfaces as a 5xx; only a missing secret should produce 409. - if ((await this.configService.getEduConnectionInfo(partner.id)) === null) { + if (!(await this.eduPool.canConnect(partner.id))) { return { status: 'ERROR' as const, type: 'conflict' as const, @@ -252,22 +250,7 @@ export class EarthbeamApiService { const partnerId = job.tenant.partnerId; const crossYearEnabled = job.tenant.partner.crossYearMatchingEnabled; - let eduCredsAvailable = false; - if (crossYearEnabled) { - try { - eduCredsAvailable = (await this.configService.getEduConnectionInfo(partnerId)) !== null; - } catch (err) { - // Degrade gracefully so a transient Secrets Manager failure doesn't - // break unrelated run creation. The roster endpoint calls - // getEduConnectionInfo directly and lets real failures propagate. - this.logger.warn( - `EDU creds lookup failed for partner ${partnerId}: ${ - err instanceof Error ? `${err.name}: ${err.message}` : String(err) - }` - ); - } - } - const crossYearMatchAvailable = crossYearEnabled && eduCredsAvailable; + const crossYearMatchAvailable = crossYearEnabled && (await this.eduPool.canConnect(partnerId)); const payload: EarthbeamApiJobResponseDto = { appDataBasePath: `${job.fileProtocol}://${job.fileBucketOrHost}/${job.fileBasePath}`, diff --git a/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts b/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts index f20b7bda..13dc9d07 100644 --- a/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts +++ b/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts @@ -16,6 +16,9 @@ type PoolEntry = { pool: Pool }; export class EduSnowflakePoolService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(EduSnowflakePoolService.name); private readonly pools = new Map>(); + // Partners for which createPool has already resolved successfully. Lets + // canConnect answer instantly for warm partners without a fresh AWS fetch. + private readonly resolvedPools = new Set(); constructor( @Inject(PRISMA_READ_ONLY) @@ -35,6 +38,7 @@ export class EduSnowflakePoolService implements OnModuleInit, OnModuleDestroy { async onModuleDestroy(): Promise { const entries = Array.from(this.pools.entries()); this.pools.clear(); + this.resolvedPools.clear(); if (entries.length === 0) return; this.logger.log(`shutting down ${entries.length} EDU snowflake pool(s)`); @@ -68,6 +72,27 @@ export class EduSnowflakePoolService implements OnModuleInit, OnModuleDestroy { return entry.pool.use(callback); } + /** + * Answers "could we open an EDU connection for this partner if asked?" + * - Already-established pool → instant true (no AWS call). + * - Otherwise → fresh cred check via AppConfigService. + * Errors are swallowed and logged so callers (e.g. job-payload assembly) + * can degrade gracefully without breaking unrelated work. + */ + async canConnect(partnerId: string): Promise { + if (this.resolvedPools.has(partnerId)) return true; + try { + return (await this.configService.getEduConnectionInfo(partnerId)) !== null; + } catch (err) { + this.logger.warn( + `canConnect: cred check failed for partner ${partnerId}: ${ + err instanceof Error ? `${err.name}: ${err.message}` : String(err) + }` + ); + return false; + } + } + private getOrCreatePool(partnerId: string): Promise { let entry = this.pools.get(partnerId); if (!entry) { @@ -80,6 +105,7 @@ export class EduSnowflakePoolService implements OnModuleInit, OnModuleDestroy { entry.catch(() => { if (this.pools.get(partnerId) === failedEntry) { this.pools.delete(partnerId); + this.resolvedPools.delete(partnerId); } }); } @@ -117,6 +143,7 @@ export class EduSnowflakePoolService implements OnModuleInit, OnModuleDestroy { ); this.logger.log(`created EDU snowflake pool for partner ${partnerId}`); + this.resolvedPools.add(partnerId); return { pool }; } From 69a637629ce27a815538403a663e7d67d3e85c0b Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Fri, 22 May 2026 09:02:43 -0500 Subject: [PATCH 33/46] drop duplicate cred check from roster context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getCrossYearRosterContext was calling eduPool.canConnect to decide between 409 and proceeding to stream. with the secrets cache gone for EDU, that meant a cold roster request did two uncached AWS lookups — one for the prereq check, one for createPool inside the stream. drop the check. the toggle check still answers "feature off for this partner" (409). everything else flows through the stream attempt; pool creation failures hit the controller's headersSent-aware catch and turn into a clean 500. the existing "missing EDU creds" test moves from 409 to 500 (and still exercises the real pool-creation failure path, unlike the mocked-pool 500 test). --- app/api/integration/tests/earthbeam-api.spec.ts | 8 +++++--- app/api/src/earthbeam/api/earthbeam-api.service.ts | 11 ++++------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/app/api/integration/tests/earthbeam-api.spec.ts b/app/api/integration/tests/earthbeam-api.spec.ts index 9b1ec5a7..c43eb6b7 100644 --- a/app/api/integration/tests/earthbeam-api.spec.ts +++ b/app/api/integration/tests/earthbeam-api.spec.ts @@ -387,16 +387,18 @@ describe('Earthbeam API', () => { expect(res.status).toBe(409); }); - it('returns 409 when EDU creds are missing', async () => { + it('returns 500 when EDU creds are missing for an otherwise-enabled partner', async () => { await global.prisma.partner.update({ where: { id: partnerA.id }, data: { crossYearMatchingEnabled: true }, }); - // env vars deliberately not set + // env vars deliberately not set — pool creation will fail before any + // rows are written; controller's headersSent check should convert that + // to a clean 500 rather than tearing the socket. const res = await request(app.getHttpServer()) .get(endpointA) .set('Authorization', `Bearer ${tokenA}`); - expect(res.status).toBe(409); + expect(res.status).toBe(500); }); it('streams NDJSON rows from the real streamCrossYearRoster, binding tenant.code', async () => { diff --git a/app/api/src/earthbeam/api/earthbeam-api.service.ts b/app/api/src/earthbeam/api/earthbeam-api.service.ts index a1a09e1c..566798e8 100644 --- a/app/api/src/earthbeam/api/earthbeam-api.service.ts +++ b/app/api/src/earthbeam/api/earthbeam-api.service.ts @@ -68,13 +68,10 @@ export class EarthbeamApiService { message: 'Cross-year matching is not enabled for this partner', }; } - if (!(await this.eduPool.canConnect(partner.id))) { - return { - status: 'ERROR' as const, - type: 'conflict' as const, - message: 'EDU connection info is not available for this partner', - }; - } + // Don't re-check creds here — the stream attempt will fail loudly if the + // pool can't be built, and the controller's headersSent-aware catch + // converts that to a clean 500. Re-checking would mean two AWS round-trips + // per cold roster request (this check + pool creation). return { status: 'SUCCESS' as const, data: { partnerId: partner.id, tenantCode: run.job.tenant.code }, From af905f2da8fe84528989d3f7436e005d1219d3bd Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Fri, 22 May 2026 10:52:00 -0500 Subject: [PATCH 34/46] prefix EDU creds secret name with ENVLABEL So multiple environments (dev/stg/prod) sharing one AWS account can keep distinct EDU connection secrets per partner. Throws if ENVLABEL is unset, matching the existing ECS-config helper. Co-Authored-By: Claude Opus 4.7 --- app/api/src/config/app-config.service.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/api/src/config/app-config.service.ts b/app/api/src/config/app-config.service.ts index 644e5bc7..11a059c2 100644 --- a/app/api/src/config/app-config.service.ts +++ b/app/api/src/config/app-config.service.ts @@ -99,8 +99,8 @@ export class AppConfigService { /** * EDU Snowflake connection info for cross-year ID matching. Looks for an - * AWS secret named `edu-connection-info-`; falls back to - * EDU_SNOWFLAKE_* env vars only in local development. Returns null when no + * AWS secret named `-edu-connection-info-`; falls back + * to EDU_SNOWFLAKE_* env vars only in local development. Returns null when no * creds are available — caller decides how to handle. Throws on real AWS * failures (IAM, throttling, network, malformed JSON) so the roster * endpoint can surface a 5xx rather than masquerading as 409 "creds @@ -125,7 +125,13 @@ export class AppConfigService { }; } - const secretName = `edu-connection-info-${partnerId}`; + const envLabel = this.get('ENVLABEL'); + if (!envLabel) { + throw new Error( + 'ENVLABEL must be set in order to retrieve EDU connection info' + ); + } + const secretName = `${envLabel}-edu-connection-info-${partnerId}`; let secret: string | Record; try { // Uncached: cred-rotation handling lives in EduSnowflakePoolService, From 3558e95b18e29711c46fcfd602d05c35e1e4460a Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Fri, 22 May 2026 11:04:27 -0500 Subject: [PATCH 35/46] inline getCrossYearRosterContext into the controller Method had one caller and most of its LOC were SUCCESS/ERROR object construction. The controller now does the run lookup + toggle check directly. partnerId/tenantCode are read from run.job scalars for symmetry between the two args. Co-Authored-By: Claude Opus 4.7 --- .../earthbeam/api/earthbeam-api.controller.ts | 22 ++++++++++---- .../earthbeam/api/earthbeam-api.service.ts | 30 ------------------- 2 files changed, 16 insertions(+), 36 deletions(-) diff --git a/app/api/src/earthbeam/api/earthbeam-api.controller.ts b/app/api/src/earthbeam/api/earthbeam-api.controller.ts index d0b8cde3..760b62f1 100644 --- a/app/api/src/earthbeam/api/earthbeam-api.controller.ts +++ b/app/api/src/earthbeam/api/earthbeam-api.controller.ts @@ -76,17 +76,27 @@ export class EarthbeamApiController { @Get(':runId/roster') async streamRoster(@Param('runId', ParseIntPipe) runId: number, @Res() res: Response) { - const ctx = await this.earthbeamApiService.getCrossYearRosterContext(runId); - if (ctx.status === 'ERROR') { - if (ctx.type === 'not_found') throw new NotFoundException(ctx.message); - throw new ConflictException(ctx.message); + const run = await this.prisma.run.findUnique({ + where: { id: runId }, + include: { job: { include: { tenant: { include: { partner: true } } } } }, + }); + if (!run) { + throw new NotFoundException(`Run not found: ${runId}`); + } + const { partner } = run.job.tenant; + if (!partner.crossYearMatchingEnabled) { + throw new ConflictException('Cross-year matching is not enabled for this partner'); } + // Don't pre-check creds here — the stream attempt will fail loudly if the + // pool can't be built, and the headersSent-aware catch below converts that + // to a clean 500. Re-checking would mean two AWS round-trips per cold + // roster request (this check + pool creation). res.setHeader('Content-Type', 'application/x-ndjson'); try { await this.earthbeamApiService.streamCrossYearRoster({ - partnerId: ctx.data.partnerId, - tenantCode: ctx.data.tenantCode, + partnerId: run.job.partnerId, + tenantCode: run.job.tenantCode, response: res, }); } catch (err) { diff --git a/app/api/src/earthbeam/api/earthbeam-api.service.ts b/app/api/src/earthbeam/api/earthbeam-api.service.ts index 566798e8..71639397 100644 --- a/app/api/src/earthbeam/api/earthbeam-api.service.ts +++ b/app/api/src/earthbeam/api/earthbeam-api.service.ts @@ -48,36 +48,6 @@ export class EarthbeamApiService { private readonly eduPool: EduSnowflakePoolService ) {} - async getCrossYearRosterContext(runId: Run['id']) { - const run = await this.prisma.run.findUnique({ - where: { id: runId }, - include: { job: { include: { tenant: { include: { partner: true } } } } }, - }); - if (!run) { - return { - status: 'ERROR' as const, - type: 'not_found' as const, - message: `Run not found: ${runId}`, - }; - } - const partner = run.job.tenant.partner; - if (!partner.crossYearMatchingEnabled) { - return { - status: 'ERROR' as const, - type: 'conflict' as const, - message: 'Cross-year matching is not enabled for this partner', - }; - } - // Don't re-check creds here — the stream attempt will fail loudly if the - // pool can't be built, and the controller's headersSent-aware catch - // converts that to a clean 500. Re-checking would mean two AWS round-trips - // per cold roster request (this check + pool creation). - return { - status: 'SUCCESS' as const, - data: { partnerId: partner.id, tenantCode: run.job.tenant.code }, - }; - } - /** * Streams a cross-year roster from EDU/Snowflake to the response as NDJSON. * Uses a partner-scoped connection pool; on stream error the response is From 1f70555b2ca60de87dbf86e8e71dc86803780e24 Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Fri, 22 May 2026 11:14:54 -0500 Subject: [PATCH 36/46] inline streamCrossYearRoster into the controller Single caller, just a query + response. Controller now injects EduSnowflakePoolService directly and owns the full Snowflake-to-NDJSON pipeline. Co-Authored-By: Claude Opus 4.7 --- .../earthbeam/api/earthbeam-api.controller.ts | 62 ++++++++++++++-- .../earthbeam/api/earthbeam-api.service.ts | 72 ------------------- 2 files changed, 57 insertions(+), 77 deletions(-) diff --git a/app/api/src/earthbeam/api/earthbeam-api.controller.ts b/app/api/src/earthbeam/api/earthbeam-api.controller.ts index 760b62f1..f56fbe61 100644 --- a/app/api/src/earthbeam/api/earthbeam-api.controller.ts +++ b/app/api/src/earthbeam/api/earthbeam-api.controller.ts @@ -28,9 +28,12 @@ import { toEarthbeamApiJobResponseDto, } from '@edanalytics/models'; import { EarthbeamApiService } from './earthbeam-api.service'; +import { EduSnowflakePoolService } from './edu-snowflake-pool.service'; import { PRISMA_ANONYMOUS } from 'api/src/database'; import { Prisma, PrismaClient } from '@prisma/client'; import { FileService } from 'api/src/files/file.service'; +import { Transform } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; @Controller() @Public() @@ -41,7 +44,8 @@ export class EarthbeamApiController { constructor( private readonly earthbeamApiService: EarthbeamApiService, @Inject(PRISMA_ANONYMOUS) private prisma: PrismaClient, - private readonly fileService: FileService + private readonly fileService: FileService, + private readonly eduPool: EduSnowflakePoolService ) {} @Get(':runId') @@ -93,12 +97,60 @@ export class EarthbeamApiController { // roster request (this check + pool creation). res.setHeader('Content-Type', 'application/x-ndjson'); + const { partnerId, tenantCode } = run.job; + const startedAt = Date.now(); + let rowCount = 0; try { - await this.earthbeamApiService.streamCrossYearRoster({ - partnerId: run.job.partnerId, - tenantCode: run.job.tenantCode, - response: res, + await this.eduPool.use(partnerId, async (connection) => { + const sqlText = ` + WITH ids AS ( + SELECT + seoa.tenant_code, + seoa.api_year, + seoa.k_student, + seoa.k_student_xyear, + seoa.student_unique_id, + seoa.ed_org_id, + seo_ids.id_system, + OBJECT_CONSTRUCT_KEEP_NULL( + 'studentIdentificationSystemDescriptor', seo_ids.id_system, + 'identificationCode', seo_ids.id_code + ) AS stu_id_code + FROM stg_ef3__student_education_organization_associations seoa + LEFT JOIN stg_ef3__stu_ed_org__identification_codes seo_ids + ON seoa.k_student = seo_ids.k_student + WHERE seoa.tenant_code = :1 + QUALIFY MAX(seoa.api_year) OVER (PARTITION BY seoa.k_student_xyear) = seoa.api_year + ) + SELECT + OBJECT_CONSTRUCT( + 'educationOrganizationId', ed_org_id, + 'link', OBJECT_CONSTRUCT('rel', 'LocalEducationAgency') + ) AS "educationOrganizationReference", + OBJECT_CONSTRUCT('studentUniqueId', student_unique_id) AS "studentReference", + ARRAY_AGG(DISTINCT stu_id_code) AS "studentIdentificationCodes" + FROM ids + GROUP BY ALL + `; + + // pipeline manages backpressure and destroys downstream streams on error + await pipeline( + connection.execute({ sqlText, binds: [tenantCode], streamResult: true }).streamRows(), + new Transform({ + writableObjectMode: true, + transform(row, _enc, cb) { + rowCount += 1; + cb(null, JSON.stringify(row) + '\n'); + }, + }), + res + ); }); + this.logger.log( + `cross-year roster: partnerId=${partnerId} tenantCode=${tenantCode} rowCount=${rowCount} durationMs=${ + Date.now() - startedAt + }` + ); } catch (err) { this.logger.error( `cross-year roster fetch failed for run ${runId}: ${err instanceof Error ? err.message : String(err)}` diff --git a/app/api/src/earthbeam/api/earthbeam-api.service.ts b/app/api/src/earthbeam/api/earthbeam-api.service.ts index 71639397..9c2e9ba1 100644 --- a/app/api/src/earthbeam/api/earthbeam-api.service.ts +++ b/app/api/src/earthbeam/api/earthbeam-api.service.ts @@ -30,9 +30,6 @@ import { EventEmitterService, EVENT_EMITTER_SERVICE, } from 'api/src/event-emitter/event-emitter.service'; -import type { Response } from 'express'; -import { Transform } from 'node:stream'; -import { pipeline } from 'node:stream/promises'; import { EduSnowflakePoolService } from './edu-snowflake-pool.service'; @Injectable() @@ -48,75 +45,6 @@ export class EarthbeamApiService { private readonly eduPool: EduSnowflakePoolService ) {} - /** - * Streams a cross-year roster from EDU/Snowflake to the response as NDJSON. - * Uses a partner-scoped connection pool; on stream error the response is - * destroyed (no in-band sentinel) — the Executor detects truncation and - * fails the run. - */ - async streamCrossYearRoster({ - partnerId, - tenantCode, - response, - }: { - partnerId: string; - tenantCode: string; - response: Response; - }): Promise { - const startedAt = Date.now(); - let rowCount = 0; - await this.eduPool.use(partnerId, async (connection) => { - const sqlText = ` - WITH ids AS ( - SELECT - seoa.tenant_code, - seoa.api_year, - seoa.k_student, - seoa.k_student_xyear, - seoa.student_unique_id, - seoa.ed_org_id, - seo_ids.id_system, - OBJECT_CONSTRUCT_KEEP_NULL( - 'studentIdentificationSystemDescriptor', seo_ids.id_system, - 'identificationCode', seo_ids.id_code - ) AS stu_id_code - FROM stg_ef3__student_education_organization_associations seoa - LEFT JOIN stg_ef3__stu_ed_org__identification_codes seo_ids - ON seoa.k_student = seo_ids.k_student - WHERE seoa.tenant_code = :1 - QUALIFY MAX(seoa.api_year) OVER (PARTITION BY seoa.k_student_xyear) = seoa.api_year - ) - SELECT - OBJECT_CONSTRUCT( - 'educationOrganizationId', ed_org_id, - 'link', OBJECT_CONSTRUCT('rel', 'LocalEducationAgency') - ) AS "educationOrganizationReference", - OBJECT_CONSTRUCT('studentUniqueId', student_unique_id) AS "studentReference", - ARRAY_AGG(DISTINCT stu_id_code) AS "studentIdentificationCodes" - FROM ids - GROUP BY ALL - `; - - // pipeline manages backpressure and destroys downstream streams on error - await pipeline( - connection.execute({ sqlText, binds: [tenantCode], streamResult: true }).streamRows(), - new Transform({ - writableObjectMode: true, - transform(row, _enc, cb) { - rowCount += 1; - cb(null, JSON.stringify(row) + '\n'); - }, - }), - response - ); - }); - this.logger.log( - `cross-year roster: partnerId=${partnerId} tenantCode=${tenantCode} rowCount=${rowCount} durationMs=${ - Date.now() - startedAt - }` - ); - } - async earthbeamInputForRun(runId: Run['id']) { const run = await this.prisma.run.findUnique({ where: { id: runId }, From 860cc8506d25f6560ddba699d2918c45dc4a4e84 Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Fri, 22 May 2026 12:10:31 -0500 Subject: [PATCH 37/46] simplify EDU pool lifecycle: min:0 + evictor, drop warmPools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snowflake server-side session timeouts would silently kill the min:1 warm connection after hours of idle, causing the next request to fail on a stale handle. Switched to min:0 with eviction every 60s on a 60s idle threshold, per Snowflake's docs. With min:0 the startup warm-up creates pool objects but no actual sockets, so it became dead weight — removed warmPools, OnModuleInit, and the now-unused PRISMA_READ_ONLY dep. First request per partner unconditionally pays the ~20s JWT connect cost; fine for a temporary feature on low-traffic partners. Co-Authored-By: Claude Opus 4.7 --- .../api/edu-snowflake-pool.service.ts | 58 ++++++------------- 1 file changed, 19 insertions(+), 39 deletions(-) diff --git a/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts b/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts index 13dc9d07..a7299837 100644 --- a/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts +++ b/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts @@ -1,34 +1,23 @@ -import { Inject, Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; -import { PrismaClient } from '@prisma/client'; -import { PRISMA_READ_ONLY } from 'api/src/database'; +import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common'; import { AppConfigService } from 'api/src/config/app-config.service'; import type { Connection, Pool } from 'snowflake-sdk'; type PoolEntry = { pool: Pool }; /** - * Maintains one Snowflake connection pool per partner. Connecting takes ~20s, - * so we warm pools at startup (fire-and-forget) and reuse connections across - * requests. A request for a partner without a pool will create one on demand - * and await its readiness. + * Maintains one Snowflake connection pool per partner, created on demand. + * The first request for a partner pays the ~20s JWT connect cost; subsequent + * requests reuse the pool until the evictor reaps idle connections. */ @Injectable() -export class EduSnowflakePoolService implements OnModuleInit, OnModuleDestroy { +export class EduSnowflakePoolService implements OnModuleDestroy { private readonly logger = new Logger(EduSnowflakePoolService.name); private readonly pools = new Map>(); // Partners for which createPool has already resolved successfully. Lets // canConnect answer instantly for warm partners without a fresh AWS fetch. private readonly resolvedPools = new Set(); - constructor( - @Inject(PRISMA_READ_ONLY) - private readonly prisma: PrismaClient, - private readonly configService: AppConfigService - ) {} - - onModuleInit() { - void this.warmPools(); - } + constructor(private readonly configService: AppConfigService) {} /** * Drain and clear every pool on shutdown so Snowflake sockets don't outlive @@ -139,32 +128,23 @@ export class EduSnowflakePoolService implements OnModuleInit, OnModuleDestroy { // instead of hanging the request forever (generic-pool's default). 60s // is intentionally generous — JWT connect alone is ~20s, so this needs // headroom for cold-start + queue wait in a bursty 5+ concurrent run. - { min: 1, max: 4, acquireTimeoutMillis: 60_000 } + // + // Evictor runs every 60s and closes connections idle for 60s. Combined + // with min: 0, this means a pool that hasn't served traffic for a minute + // drops to zero connections, avoiding stale-session failures on the next + // request (Snowflake server-side session timeout would otherwise kill + // the idle connection silently). + { + min: 0, + max: 4, + acquireTimeoutMillis: 60_000, + evictionRunIntervalMillis: 60_000, + idleTimeoutMillis: 60_000, + } ); this.logger.log(`created EDU snowflake pool for partner ${partnerId}`); this.resolvedPools.add(partnerId); return { pool }; } - - private async warmPools(): Promise { - try { - const partners = await this.prisma.partner.findMany({ - where: { crossYearMatchingEnabled: true }, - select: { id: true }, - }); - if (partners.length === 0) return; - - this.logger.log(`warming EDU snowflake pools for ${partners.length} partner(s)`); - await Promise.allSettled( - partners.map((p) => - this.getOrCreatePool(p.id).catch((err) => - this.logger.warn(`pool warm failed for partner ${p.id}: ${err}`) - ) - ) - ); - } catch (err) { - this.logger.warn(`pool warming aborted: ${err}`); - } - } } From ad5832757fcf85d603af8e5e73b2dbc934b5ae5d Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Fri, 22 May 2026 13:00:25 -0500 Subject: [PATCH 38/46] tidy EduSnowflakePoolService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop resolvedPools — pools.has gives the same fast-path for canConnect with a benign in-flight-failure race. - Early-return in getOrCreatePool; remove failedEntry rename now that the catch handler closes over a single entry. - Drop the single-field PoolEntry wrapper; map and methods carry Pool directly. Co-Authored-By: Claude Opus 4.7 --- .../api/edu-snowflake-pool.service.ts | 67 +++++++++---------- 1 file changed, 31 insertions(+), 36 deletions(-) diff --git a/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts b/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts index a7299837..64406506 100644 --- a/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts +++ b/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts @@ -2,8 +2,6 @@ import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common'; import { AppConfigService } from 'api/src/config/app-config.service'; import type { Connection, Pool } from 'snowflake-sdk'; -type PoolEntry = { pool: Pool }; - /** * Maintains one Snowflake connection pool per partner, created on demand. * The first request for a partner pays the ~20s JWT connect cost; subsequent @@ -12,10 +10,7 @@ type PoolEntry = { pool: Pool }; @Injectable() export class EduSnowflakePoolService implements OnModuleDestroy { private readonly logger = new Logger(EduSnowflakePoolService.name); - private readonly pools = new Map>(); - // Partners for which createPool has already resolved successfully. Lets - // canConnect answer instantly for warm partners without a fresh AWS fetch. - private readonly resolvedPools = new Set(); + private readonly pools = new Map>>(); constructor(private readonly configService: AppConfigService) {} @@ -27,15 +22,14 @@ export class EduSnowflakePoolService implements OnModuleDestroy { async onModuleDestroy(): Promise { const entries = Array.from(this.pools.entries()); this.pools.clear(); - this.resolvedPools.clear(); if (entries.length === 0) return; this.logger.log(`shutting down ${entries.length} EDU snowflake pool(s)`); const shutdownTimeoutMs = 10_000; await Promise.allSettled( - entries.map(async ([partnerId, entryPromise]) => { + entries.map(async ([partnerId, poolPromise]) => { try { - const { pool } = await entryPromise; + const pool = await poolPromise; await Promise.race([ pool.drain().then(() => pool.clear()), new Promise((_, reject) => @@ -57,19 +51,23 @@ export class EduSnowflakePoolService implements OnModuleDestroy { * releases the connection. Creates the pool on demand if none exists. */ async use(partnerId: string, callback: (connection: Connection) => Promise): Promise { - const entry = await this.getOrCreatePool(partnerId); - return entry.pool.use(callback); + const pool = await this.getOrCreatePool(partnerId); + return pool.use(callback); } /** * Answers "could we open an EDU connection for this partner if asked?" - * - Already-established pool → instant true (no AWS call). - * - Otherwise → fresh cred check via AppConfigService. - * Errors are swallowed and logged so callers (e.g. job-payload assembly) - * can degrade gracefully without breaking unrelated work. + * Fast path: an entry in `pools` means we've already started building a + * pool, so creds existed at least once — return true without an AWS call. + * (A racy in-flight failure is harmless: payload assembly would set + * crossYearMatchAvailable=true, the executor's roster fetch would 500, + * and the executor falls back to first-pass results.) + * Otherwise: fresh cred check via AppConfigService. Errors are swallowed + * and logged so callers (e.g. job-payload assembly) can degrade gracefully + * without breaking unrelated work. */ async canConnect(partnerId: string): Promise { - if (this.resolvedPools.has(partnerId)) return true; + if (this.pools.has(partnerId)) return true; try { return (await this.configService.getEduConnectionInfo(partnerId)) !== null; } catch (err) { @@ -82,26 +80,24 @@ export class EduSnowflakePoolService implements OnModuleDestroy { } } - private getOrCreatePool(partnerId: string): Promise { - let entry = this.pools.get(partnerId); - if (!entry) { - entry = this.createPool(partnerId); - this.pools.set(partnerId, entry); - // Evict failed creations so subsequent requests can retry. Guard against - // racing with a concurrent insertion under the same key — only delete if - // the map still points at this failed entry. - const failedEntry = entry; - entry.catch(() => { - if (this.pools.get(partnerId) === failedEntry) { - this.pools.delete(partnerId); - this.resolvedPools.delete(partnerId); - } - }); - } - return entry; + private getOrCreatePool(partnerId: string): Promise> { + const existing = this.pools.get(partnerId); + if (existing) return existing; + + const pool = this.createPool(partnerId); + this.pools.set(partnerId, pool); + // Evict failed creations so subsequent requests can retry. Guard against + // racing with a concurrent insertion under the same key — only delete if + // the map still points at this entry. + pool.catch(() => { + if (this.pools.get(partnerId) === pool) { + this.pools.delete(partnerId); + } + }); + return pool; } - private async createPool(partnerId: string): Promise { + private async createPool(partnerId: string): Promise> { const conn = await this.configService.getEduConnectionInfo(partnerId); if (!conn) { throw new Error(`No EDU connection info available for partner ${partnerId}`); @@ -144,7 +140,6 @@ export class EduSnowflakePoolService implements OnModuleDestroy { ); this.logger.log(`created EDU snowflake pool for partner ${partnerId}`); - this.resolvedPools.add(partnerId); - return { pool }; + return pool; } } From 595ede33f3c8b52e368072008acab3f242f90c52 Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Fri, 22 May 2026 13:33:49 -0500 Subject: [PATCH 39/46] test: mock getEduConnectionInfo instead of mutating env vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cross-year payload and roster integration tests drove getEduConnectionInfo via process.env (NODE_ENV=development + EDU_SNOWFLAKE_*), with a duplicated save/restore harness in each describe block. Switched to a jest spy on AppConfigService.getEduConnectionInfo — one beforeEach per block, default mockResolvedValue(null), per-test override where creds need to be present. Also dropped edu-config.spec.ts entirely: both tests only exercised the local-dev env-var fallback (never the AWS Secrets Manager path that runs in deployed environments), and the same dev-fallback shape is now implicitly covered through the AppConfigService spy seam. Co-Authored-By: Claude Opus 4.7 --- .../integration/tests/earthbeam-api.spec.ts | 98 +++++-------------- app/api/integration/tests/edu-config.spec.ts | 64 ------------ 2 files changed, 25 insertions(+), 137 deletions(-) delete mode 100644 app/api/integration/tests/edu-config.spec.ts diff --git a/app/api/integration/tests/earthbeam-api.spec.ts b/app/api/integration/tests/earthbeam-api.spec.ts index c43eb6b7..b64e2a10 100644 --- a/app/api/integration/tests/earthbeam-api.spec.ts +++ b/app/api/integration/tests/earthbeam-api.spec.ts @@ -1,5 +1,6 @@ import { EarthbeamApiAuthService } from 'api/src/earthbeam/api/auth/earthbeam-api-auth.service'; import { EduSnowflakePoolService } from 'api/src/earthbeam/api/edu-snowflake-pool.service'; +import { AppConfigService } from 'api/src/config/app-config.service'; import { Readable } from 'node:stream'; import request from 'supertest'; import { seedJob } from '../factories/job-factory'; @@ -83,41 +84,15 @@ describe('Earthbeam API', () => { }); describe('cross-year ID matching', () => { - const EDU_ENV_VARS = [ - 'NODE_ENV', - 'EDU_SNOWFLAKE_USERNAME', - 'EDU_SNOWFLAKE_ACCOUNT', - 'EDU_SNOWFLAKE_DATABASE', - 'EDU_SNOWFLAKE_SCHEMA', - 'EDU_SNOWFLAKE_PRIVATE_KEY', - ] as const; - const savedEnv: Record = {}; - - const setEduEnvVars = () => { - process.env.EDU_SNOWFLAKE_USERNAME = 'snowflake-user'; - process.env.EDU_SNOWFLAKE_ACCOUNT = 'example'; - process.env.EDU_SNOWFLAKE_DATABASE = 'edu_stg'; - process.env.EDU_SNOWFLAKE_SCHEMA = 'public'; - process.env.EDU_SNOWFLAKE_PRIVATE_KEY = Buffer.from('priv').toString('base64'); - }; + let getInfoSpy: jest.SpyInstance; beforeEach(() => { - for (const key of EDU_ENV_VARS) { - savedEnv[key] = process.env[key]; - delete process.env[key]; - } - // The env-var fallback in AppConfigService is gated on NODE_ENV=development. - process.env.NODE_ENV = 'development'; + const configService = app.get(AppConfigService); + getInfoSpy = jest.spyOn(configService, 'getEduConnectionInfo').mockResolvedValue(null); }); afterEach(() => { - for (const key of EDU_ENV_VARS) { - if (savedEnv[key] === undefined) { - delete process.env[key]; - } else { - process.env[key] = savedEnv[key]; - } - } + getInfoSpy.mockRestore(); }); it('sets crossYearMatchAvailable=true and emits appUrls.roster when toggle on and creds exist', async () => { @@ -125,7 +100,13 @@ describe('Earthbeam API', () => { where: { id: partnerA.id }, data: { crossYearMatchingEnabled: true }, }); - setEduEnvVars(); + getInfoSpy.mockResolvedValue({ + username: 'snowflake-user', + account: 'example', + database: 'edu_stg', + schema: 'public', + privateKey: Buffer.from('priv'), + }); const res = await request(app.getHttpServer()) .get(endpointA) @@ -138,9 +119,8 @@ describe('Earthbeam API', () => { }); it('sets crossYearMatchAvailable=false and omits appUrls.roster when toggle is off', async () => { - // partnerA defaults to crossYearMatchingEnabled=false - setEduEnvVars(); - + // partnerA defaults to crossYearMatchingEnabled=false — canConnect is + // short-circuited before the config is consulted. const res = await request(app.getHttpServer()) .get(endpointA) .set('Authorization', `Bearer ${tokenA}`); @@ -155,7 +135,7 @@ describe('Earthbeam API', () => { where: { id: partnerA.id }, data: { crossYearMatchingEnabled: true }, }); - // env vars deliberately not set + // default spy returns null — simulates "no creds available" const res = await request(app.getHttpServer()) .get(endpointA) @@ -323,33 +303,15 @@ describe('Earthbeam API', () => { let endpointA: string; let tokenA: string; let poolUseSpy: jest.SpyInstance | undefined; - - const EDU_ENV_VARS = [ - 'NODE_ENV', - 'EDU_SNOWFLAKE_USERNAME', - 'EDU_SNOWFLAKE_ACCOUNT', - 'EDU_SNOWFLAKE_DATABASE', - 'EDU_SNOWFLAKE_SCHEMA', - 'EDU_SNOWFLAKE_PRIVATE_KEY', - ] as const; - const savedEnv: Record = {}; - - const setEduEnvVars = () => { - process.env.EDU_SNOWFLAKE_USERNAME = 'snowflake-user'; - process.env.EDU_SNOWFLAKE_ACCOUNT = 'example'; - process.env.EDU_SNOWFLAKE_DATABASE = 'edu_stg'; - process.env.EDU_SNOWFLAKE_SCHEMA = 'public'; - process.env.EDU_SNOWFLAKE_PRIVATE_KEY = Buffer.from('priv').toString('base64'); - }; + let getInfoSpy: jest.SpyInstance; beforeEach(async () => { - for (const key of EDU_ENV_VARS) { - savedEnv[key] = process.env[key]; - delete process.env[key]; - } - // The env-var fallback in AppConfigService is gated on NODE_ENV=development. - process.env.NODE_ENV = 'development'; poolUseSpy = undefined; + const configService = app.get(AppConfigService); + // Default to "no creds" — tests that exercise the creds-present path + // either override this with mockResolvedValue, or short-circuit the + // config lookup entirely by spying on eduPool.use. + getInfoSpy = jest.spyOn(configService, 'getEduConnectionInfo').mockResolvedValue(null); const authService = app.get(EarthbeamApiAuthService); const jobA = await seedJob({ @@ -363,14 +325,8 @@ describe('Earthbeam API', () => { }); afterEach(() => { - for (const key of EDU_ENV_VARS) { - if (savedEnv[key] === undefined) { - delete process.env[key]; - } else { - process.env[key] = savedEnv[key]; - } - } poolUseSpy?.mockRestore(); + getInfoSpy.mockRestore(); }); it('rejects unauthenticated requests', async () => { @@ -380,7 +336,6 @@ describe('Earthbeam API', () => { it('returns 409 when the partner has cross-year matching disabled', async () => { // partnerA defaults to crossYearMatchingEnabled=false - setEduEnvVars(); const res = await request(app.getHttpServer()) .get(endpointA) .set('Authorization', `Bearer ${tokenA}`); @@ -392,9 +347,9 @@ describe('Earthbeam API', () => { where: { id: partnerA.id }, data: { crossYearMatchingEnabled: true }, }); - // env vars deliberately not set — pool creation will fail before any - // rows are written; controller's headersSent check should convert that - // to a clean 500 rather than tearing the socket. + // default spy returns null — pool creation will fail before any rows + // are written; controller's headersSent check should convert that to a + // clean 500 rather than tearing the socket. const res = await request(app.getHttpServer()) .get(endpointA) .set('Authorization', `Bearer ${tokenA}`); @@ -406,7 +361,6 @@ describe('Earthbeam API', () => { where: { id: partnerA.id }, data: { crossYearMatchingEnabled: true }, }); - setEduEnvVars(); const rows = [ { studentUniqueId: '1', priorYear: 2024 }, @@ -454,7 +408,6 @@ describe('Earthbeam API', () => { where: { id: partnerA.id }, data: { crossYearMatchingEnabled: true }, }); - setEduEnvVars(); const eduPool = app.get(EduSnowflakePoolService); poolUseSpy = jest.spyOn(eduPool, 'use').mockImplementation(async (_partnerId, cb) => { @@ -502,7 +455,6 @@ describe('Earthbeam API', () => { where: { id: partnerA.id }, data: { crossYearMatchingEnabled: true }, }); - setEduEnvVars(); const eduPool = app.get(EduSnowflakePoolService); poolUseSpy = jest diff --git a/app/api/integration/tests/edu-config.spec.ts b/app/api/integration/tests/edu-config.spec.ts deleted file mode 100644 index 36fb34fb..00000000 --- a/app/api/integration/tests/edu-config.spec.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { AppConfigService } from 'api/src/config/app-config.service'; -import { partnerA } from '../fixtures/context-fixtures/partner-fixtures'; - -const EDU_ENV_VARS = [ - 'NODE_ENV', - 'EDU_SNOWFLAKE_USERNAME', - 'EDU_SNOWFLAKE_ACCOUNT', - 'EDU_SNOWFLAKE_DATABASE', - 'EDU_SNOWFLAKE_SCHEMA', - 'EDU_SNOWFLAKE_PRIVATE_KEY', -] as const; - -describe('AppConfigService — EDU Snowflake config', () => { - let configService: AppConfigService; - const savedEnv: Record = {}; - - beforeAll(() => { - configService = app.get(AppConfigService); - }); - - beforeEach(() => { - for (const key of EDU_ENV_VARS) { - savedEnv[key] = process.env[key]; - delete process.env[key]; - } - // The env-var fallback in AppConfigService is gated on NODE_ENV=development. - process.env.NODE_ENV = 'development'; - }); - - afterEach(() => { - for (const key of EDU_ENV_VARS) { - if (savedEnv[key] === undefined) { - delete process.env[key]; - } else { - process.env[key] = savedEnv[key]; - } - } - }); - - describe('getEduConnectionInfo', () => { - it('returns null when no env vars are set and no AWS secret exists', async () => { - const info = await configService.getEduConnectionInfo(partnerA.id); - expect(info).toBeNull(); - }); - - it('returns a connection info object built from env vars when set', async () => { - const privateKey = Buffer.from('private-key-content').toString('base64'); - process.env.EDU_SNOWFLAKE_USERNAME = 'snowflake-user'; - process.env.EDU_SNOWFLAKE_ACCOUNT = 'example'; - process.env.EDU_SNOWFLAKE_DATABASE = 'edu_stg'; - process.env.EDU_SNOWFLAKE_SCHEMA = 'public'; - process.env.EDU_SNOWFLAKE_PRIVATE_KEY = privateKey; - - const info = await configService.getEduConnectionInfo(partnerA.id); - expect(info).toEqual({ - username: 'snowflake-user', - account: 'example', - database: 'edu_stg', - schema: 'public', - privateKey: Buffer.from('private-key-content'), - }); - }); - }); -}); From 0c091bf4ba26d03487ca85178e70d68f3ba1bf8b Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Fri, 22 May 2026 13:56:21 -0500 Subject: [PATCH 40/46] test: tidy cross-year ID matching describe block - Move the describe to sit after the top-level its and before the Descriptor Mappings describe, so the send-to-ODS / no-ODS pair is no longer split. - Invert the default test state: beforeEach now sets cross-year fully enabled (toggle on + creds mocked present). The happy path needs no overrides; each negative test removes exactly one condition, so a broken gate can't silently make the test pass for the wrong reason. Co-Authored-By: Claude Opus 4.7 --- .../integration/tests/earthbeam-api.spec.ts | 82 +++++++++---------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/app/api/integration/tests/earthbeam-api.spec.ts b/app/api/integration/tests/earthbeam-api.spec.ts index b64e2a10..3a8c0f59 100644 --- a/app/api/integration/tests/earthbeam-api.spec.ts +++ b/app/api/integration/tests/earthbeam-api.spec.ts @@ -83,31 +83,55 @@ describe('Earthbeam API', () => { expect(res.body.rosterFilePath).toBeUndefined(); }); - describe('cross-year ID matching', () => { - let getInfoSpy: jest.SpyInstance; - - beforeEach(() => { - const configService = app.get(AppConfigService); - getInfoSpy = jest.spyOn(configService, 'getEduConnectionInfo').mockResolvedValue(null); + it('should omit ODS credentials and include a roster path for no-ODS jobs', async () => { + const authService = app.get(EarthbeamApiAuthService); + const noOdsJob = await seedJob({ + sendToOds: false, + schoolYearId: '2324', + bundle: bundleA, + tenant: tenantA, }); - afterEach(() => { - getInfoSpy.mockRestore(); - }); + const noOdsRun = noOdsJob.runs[0]; + const noOdsToken = await authService.createAccessToken({ runId: noOdsRun.id }); + const res = await request(app.getHttpServer()) + .get(`/earthbeam/jobs/${noOdsRun.id}`) + .set('Authorization', `Bearer ${noOdsToken}`); - it('sets crossYearMatchAvailable=true and emits appUrls.roster when toggle on and creds exist', async () => { + expect(res.status).toBe(200); + expect(res.body.sendToOds).toBe(false); + expect(res.body.assessmentDatastore).toBeUndefined(); + expect(res.body.rosterFilePath).toBe( + 's3://test-file-bucket/__rosters/partner-a/tenant-a/2024/studentEducationOrganizationAssociations.jsonl' + ); + }); + + describe('cross-year ID matching', () => { + // Default state per test: both gates ON (toggle enabled + creds present) + // so the happy path requires no overrides and each negative test reads + // as "remove one condition, expect the flag to flip false." + let getInfoSpy: jest.SpyInstance; + + beforeEach(async () => { await global.prisma.partner.update({ where: { id: partnerA.id }, data: { crossYearMatchingEnabled: true }, }); - getInfoSpy.mockResolvedValue({ + const configService = app.get(AppConfigService); + getInfoSpy = jest.spyOn(configService, 'getEduConnectionInfo').mockResolvedValue({ username: 'snowflake-user', account: 'example', database: 'edu_stg', schema: 'public', privateKey: Buffer.from('priv'), }); + }); + + afterEach(() => { + getInfoSpy.mockRestore(); + }); + it('sets crossYearMatchAvailable=true and emits appUrls.roster when toggle on and creds exist', async () => { const res = await request(app.getHttpServer()) .get(endpointA) .set('Authorization', `Bearer ${tokenA}`); @@ -119,8 +143,11 @@ describe('Earthbeam API', () => { }); it('sets crossYearMatchAvailable=false and omits appUrls.roster when toggle is off', async () => { - // partnerA defaults to crossYearMatchingEnabled=false — canConnect is - // short-circuited before the config is consulted. + await global.prisma.partner.update({ + where: { id: partnerA.id }, + data: { crossYearMatchingEnabled: false }, + }); + const res = await request(app.getHttpServer()) .get(endpointA) .set('Authorization', `Bearer ${tokenA}`); @@ -131,11 +158,7 @@ describe('Earthbeam API', () => { }); it('sets crossYearMatchAvailable=false and omits appUrls.roster when creds are missing', async () => { - await global.prisma.partner.update({ - where: { id: partnerA.id }, - data: { crossYearMatchingEnabled: true }, - }); - // default spy returns null — simulates "no creds available" + getInfoSpy.mockResolvedValue(null); const res = await request(app.getHttpServer()) .get(endpointA) @@ -147,29 +170,6 @@ describe('Earthbeam API', () => { }); }); - it('should omit ODS credentials and include a roster path for no-ODS jobs', async () => { - const authService = app.get(EarthbeamApiAuthService); - const noOdsJob = await seedJob({ - sendToOds: false, - schoolYearId: '2324', - bundle: bundleA, - tenant: tenantA, - }); - - const noOdsRun = noOdsJob.runs[0]; - const noOdsToken = await authService.createAccessToken({ runId: noOdsRun.id }); - const res = await request(app.getHttpServer()) - .get(`/earthbeam/jobs/${noOdsRun.id}`) - .set('Authorization', `Bearer ${noOdsToken}`); - - expect(res.status).toBe(200); - expect(res.body.sendToOds).toBe(false); - expect(res.body.assessmentDatastore).toBeUndefined(); - expect(res.body.rosterFilePath).toBe( - 's3://test-file-bucket/__rosters/partner-a/tenant-a/2024/studentEducationOrganizationAssociations.jsonl' - ); - }); - // TODO: add tests for things other than descriptor mappings describe('Authenticated requests: Descriptor Mappings', () => { const testDescriptorTypeA = 'testDescriptorTypeA'; From cdb9cbc48772448d587f3471ca7dd54d98797dae Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Fri, 22 May 2026 14:11:54 -0500 Subject: [PATCH 41/46] test: consume roster responses chunk-by-chunk in supertest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both the happy-path and mid-stream-error roster tests now use a streaming parser via .buffer(true).parse(streamParser). The parser collects chunks as they arrive and signals whether 'end' fired (complete) or 'close'/'error' fired first (truncated). The mid-stream test was previously written as "either we got a partial body or supertest threw, both are fine" because the abort behavior varied across Node/supertest combos. The streaming parser makes it deterministic — the test asserts complete=false and the exact captured bytes. Also dropped the test-local poolUseSpy declaration and afterEach cleanup; mocks created inside an `it` are now mockRestored at the bottom of the same `it`. Co-Authored-By: Claude Opus 4.7 --- .../integration/tests/earthbeam-api.spec.ts | 86 ++++++++++++------- 1 file changed, 54 insertions(+), 32 deletions(-) diff --git a/app/api/integration/tests/earthbeam-api.spec.ts b/app/api/integration/tests/earthbeam-api.spec.ts index 3a8c0f59..0b9c491c 100644 --- a/app/api/integration/tests/earthbeam-api.spec.ts +++ b/app/api/integration/tests/earthbeam-api.spec.ts @@ -302,11 +302,29 @@ describe('Earthbeam API', () => { let runA: Run; let endpointA: string; let tokenA: string; - let poolUseSpy: jest.SpyInstance | undefined; let getInfoSpy: jest.SpyInstance; + // Streaming parser for supertest: collects chunks as they arrive and + // signals whether the response ended cleanly ('end' fired) or was closed + // early ('close'/'error' fired first). Use .buffer(true).parse(streamParser). + const streamParser = ( + response: request.Response, + cb: (err: Error | null, body: { chunks: Buffer[]; complete: boolean }) => void + ) => { + const chunks: Buffer[] = []; + let settled = false; + const settle = (complete: boolean) => { + if (settled) return; + settled = true; + cb(null, { chunks, complete }); + }; + response.on('data', (chunk: Buffer) => chunks.push(chunk)); + response.on('end', () => settle(true)); + response.on('close', () => settle(false)); + response.on('error', () => settle(false)); + }; + beforeEach(async () => { - poolUseSpy = undefined; const configService = app.get(AppConfigService); // Default to "no creds" — tests that exercise the creds-present path // either override this with mockResolvedValue, or short-circuit the @@ -325,7 +343,6 @@ describe('Earthbeam API', () => { }); afterEach(() => { - poolUseSpy?.mockRestore(); getInfoSpy.mockRestore(); }); @@ -371,7 +388,7 @@ describe('Earthbeam API', () => { const eduPool = app.get(EduSnowflakePoolService); // Mock at the pool boundary so the real streamCrossYearRoster body runs // (pipeline + Transform + the real SQL + binds). - poolUseSpy = jest.spyOn(eduPool, 'use').mockImplementation(async (_partnerId, cb) => { + const poolUseSpy = jest.spyOn(eduPool, 'use').mockImplementation(async (_partnerId, cb) => { const fakeConnection = { execute: (args: { sqlText: string; binds: unknown[]; streamResult: boolean }) => { capturedExecute = args; @@ -381,13 +398,21 @@ describe('Earthbeam API', () => { return cb(fakeConnection as never); }); + // Consume the response as a stream: collect chunks, signal whether + // it ended cleanly or was closed early. This mirrors how the executor + // consumes the response and lets the mid-stream-error test below assert + // truncation deterministically. const res = await request(app.getHttpServer()) .get(endpointA) - .set('Authorization', `Bearer ${tokenA}`); + .set('Authorization', `Bearer ${tokenA}`) + .buffer(true) + .parse(streamParser); expect(res.status).toBe(200); expect(res.headers['content-type']).toContain('application/x-ndjson'); - const lines = res.text.split('\n').filter((l) => l.length > 0); + expect(res.body.complete).toBe(true); + const body = Buffer.concat(res.body.chunks).toString('utf8'); + const lines = body.split('\n').filter((l) => l.length > 0); expect(lines).toHaveLength(3); expect(JSON.parse(lines[0])).toEqual(rows[0]); expect(JSON.parse(lines[2])).toEqual(rows[2]); @@ -401,6 +426,8 @@ describe('Earthbeam API', () => { /stg_ef3__student_education_organization_associations/ ); expect(capturedExecute!.sqlText).toMatch(/seoa\.tenant_code\s*=\s*:1/); + + poolUseSpy.mockRestore(); }); it('closes the response abruptly when the Snowflake row stream errors mid-flight', async () => { @@ -410,7 +437,7 @@ describe('Earthbeam API', () => { }); const eduPool = app.get(EduSnowflakePoolService); - poolUseSpy = jest.spyOn(eduPool, 'use').mockImplementation(async (_partnerId, cb) => { + const poolUseSpy = jest.spyOn(eduPool, 'use').mockImplementation(async (_partnerId, cb) => { const errorStream = Readable.from( (async function* () { yield { studentUniqueId: '1' }; @@ -423,31 +450,24 @@ describe('Earthbeam API', () => { return cb(fakeConnection as never); }); - // Abrupt close: pipeline destroys the response on stream error. Headers - // (status + content-type) were already sent, so supertest receives a - // truncated body containing the rows written before the error. - let res: request.Response | undefined; - let sockErr: Error | undefined; - try { - res = await request(app.getHttpServer()) - .get(endpointA) - .set('Authorization', `Bearer ${tokenA}`); - } catch (err) { - sockErr = err as Error; - } + // Consume chunks as they arrive. Pipeline destroys the response on + // stream error; headers (status + content-type) were already sent, so + // the client sees the first row, then the socket is closed before + // 'end' fires. + const res = await request(app.getHttpServer()) + .get(endpointA) + .set('Authorization', `Bearer ${tokenA}`) + .buffer(true) + .parse(streamParser); - if (res) { - expect(res.status).toBe(200); - const lines = res.text.split('\n').filter((l) => l.length > 0); - expect(lines).toEqual([JSON.stringify({ studentUniqueId: '1' })]); - // No in-band sentinel / error marker — abrupt close, body simply truncates. - expect(res.text).not.toMatch(/error|exception/i); - } else { - // Some Node/supertest combinations surface the destroyed socket as a - // client-side error instead of a partial body. Either is consistent - // with "abrupt close, no sentinel." - expect(sockErr).toBeDefined(); - } + expect(res.status).toBe(200); + expect(res.body.complete).toBe(false); + const body = Buffer.concat(res.body.chunks).toString('utf8'); + expect(body).toBe(JSON.stringify({ studentUniqueId: '1' }) + '\n'); + // No in-band sentinel / error marker — abrupt close, body simply truncates. + expect(body).not.toMatch(/error|exception/i); + + poolUseSpy.mockRestore(); }); it('returns 500 when pool acquisition fails before any bytes are streamed', async () => { @@ -457,7 +477,7 @@ describe('Earthbeam API', () => { }); const eduPool = app.get(EduSnowflakePoolService); - poolUseSpy = jest + const poolUseSpy = jest .spyOn(eduPool, 'use') .mockRejectedValue(new Error('pool acquisition failed')); @@ -468,6 +488,8 @@ describe('Earthbeam API', () => { expect(res.status).toBe(500); // A clean JSON 500 — easier for the executor to diagnose than a torn socket. expect(res.headers['content-type']).toContain('application/json'); + + poolUseSpy.mockRestore(); }); }); From 1dd1b2fb4d9f0f427426f007aefc38e2c5735475 Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Fri, 22 May 2026 14:28:50 -0500 Subject: [PATCH 42/46] test: group roster streaming tests under a nested describe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract a mockEduPoolStream helper that stubs the pool's use() with a fake connection streaming from an Iterable/AsyncIterable. Each test's distinguishing setup is now a single line (the source stream). - Drop the binds assertion + rename the happy-path test — the bind value is literally visible in the controller, no derived behavior to verify. - Group the two stream-consuming tests into a `streaming responses` describe so the helpers (streamParser, mockEduPoolStream) live next to the tests that use them. The toggle-on prereq moves into the nested beforeEach. Co-Authored-By: Claude Opus 4.7 --- .../integration/tests/earthbeam-api.spec.ts | 196 ++++++++---------- 1 file changed, 83 insertions(+), 113 deletions(-) diff --git a/app/api/integration/tests/earthbeam-api.spec.ts b/app/api/integration/tests/earthbeam-api.spec.ts index 0b9c491c..492bae4f 100644 --- a/app/api/integration/tests/earthbeam-api.spec.ts +++ b/app/api/integration/tests/earthbeam-api.spec.ts @@ -302,35 +302,8 @@ describe('Earthbeam API', () => { let runA: Run; let endpointA: string; let tokenA: string; - let getInfoSpy: jest.SpyInstance; - - // Streaming parser for supertest: collects chunks as they arrive and - // signals whether the response ended cleanly ('end' fired) or was closed - // early ('close'/'error' fired first). Use .buffer(true).parse(streamParser). - const streamParser = ( - response: request.Response, - cb: (err: Error | null, body: { chunks: Buffer[]; complete: boolean }) => void - ) => { - const chunks: Buffer[] = []; - let settled = false; - const settle = (complete: boolean) => { - if (settled) return; - settled = true; - cb(null, { chunks, complete }); - }; - response.on('data', (chunk: Buffer) => chunks.push(chunk)); - response.on('end', () => settle(true)); - response.on('close', () => settle(false)); - response.on('error', () => settle(false)); - }; beforeEach(async () => { - const configService = app.get(AppConfigService); - // Default to "no creds" — tests that exercise the creds-present path - // either override this with mockResolvedValue, or short-circuit the - // config lookup entirely by spying on eduPool.use. - getInfoSpy = jest.spyOn(configService, 'getEduConnectionInfo').mockResolvedValue(null); - const authService = app.get(EarthbeamApiAuthService); const jobA = await seedJob({ odsConfig: odsConfigA2425, @@ -342,10 +315,6 @@ describe('Earthbeam API', () => { tokenA = await authService.createAccessToken({ runId: runA.id }); }); - afterEach(() => { - getInfoSpy.mockRestore(); - }); - it('rejects unauthenticated requests', async () => { const res = await request(app.getHttpServer()).get(endpointA); expect(res.status).toBe(401); @@ -364,110 +333,111 @@ describe('Earthbeam API', () => { where: { id: partnerA.id }, data: { crossYearMatchingEnabled: true }, }); - // default spy returns null — pool creation will fail before any rows - // are written; controller's headersSent check should convert that to a - // clean 500 rather than tearing the socket. + // No creds → pool creation will fail before any rows are written; + // controller's headersSent check should convert that to a clean 500 + // rather than tearing the socket. + const configService = app.get(AppConfigService); + const getInfoSpy = jest + .spyOn(configService, 'getEduConnectionInfo') + .mockResolvedValue(null); + const res = await request(app.getHttpServer()) .get(endpointA) .set('Authorization', `Bearer ${tokenA}`); expect(res.status).toBe(500); - }); - it('streams NDJSON rows from the real streamCrossYearRoster, binding tenant.code', async () => { - await global.prisma.partner.update({ - where: { id: partnerA.id }, - data: { crossYearMatchingEnabled: true }, - }); + getInfoSpy.mockRestore(); + }); - const rows = [ - { studentUniqueId: '1', priorYear: 2024 }, - { studentUniqueId: '2', priorYear: 2024 }, - { studentUniqueId: '3', priorYear: 2024 }, - ]; - let capturedExecute: { sqlText: string; binds: unknown[]; streamResult: boolean } | undefined; - const eduPool = app.get(EduSnowflakePoolService); - // Mock at the pool boundary so the real streamCrossYearRoster body runs - // (pipeline + Transform + the real SQL + binds). - const poolUseSpy = jest.spyOn(eduPool, 'use').mockImplementation(async (_partnerId, cb) => { - const fakeConnection = { - execute: (args: { sqlText: string; binds: unknown[]; streamResult: boolean }) => { - capturedExecute = args; - return { streamRows: () => Readable.from(rows) }; - }, + describe('streaming responses', () => { + // Streaming parser for supertest: collects chunks as they arrive and + // signals whether the response ended cleanly ('end' fired) or was closed + // early ('close'/'error' fired first). Use .buffer(true).parse(streamParser). + const streamParser = ( + response: request.Response, + cb: (err: Error | null, body: { chunks: Buffer[]; complete: boolean }) => void + ) => { + const chunks: Buffer[] = []; + let settled = false; + const settle = (complete: boolean) => { + if (settled) return; + settled = true; + cb(null, { chunks, complete }); }; - return cb(fakeConnection as never); + response.on('data', (chunk: Buffer) => chunks.push(chunk)); + response.on('end', () => settle(true)); + response.on('close', () => settle(false)); + response.on('error', () => settle(false)); + }; + + // Stub EduSnowflakePoolService.use with a fake connection that streams + // `source` rows. Caller is responsible for `mockRestore()`. + const mockEduPoolStream = (source: Iterable | AsyncIterable) => { + const eduPool = app.get(EduSnowflakePoolService); + return jest.spyOn(eduPool, 'use').mockImplementation(async (_partnerId, cb) => { + return cb({ + execute: () => ({ streamRows: () => Readable.from(source) }), + } as never); + }); + }; + + beforeEach(async () => { + await global.prisma.partner.update({ + where: { id: partnerA.id }, + data: { crossYearMatchingEnabled: true }, + }); }); - // Consume the response as a stream: collect chunks, signal whether - // it ended cleanly or was closed early. This mirrors how the executor - // consumes the response and lets the mid-stream-error test below assert - // truncation deterministically. - const res = await request(app.getHttpServer()) - .get(endpointA) - .set('Authorization', `Bearer ${tokenA}`) - .buffer(true) - .parse(streamParser); + it('streams the rows from the EDU pool as NDJSON', async () => { + const rows = [ + { studentUniqueId: '1', priorYear: 2024 }, + { studentUniqueId: '2', priorYear: 2024 }, + { studentUniqueId: '3', priorYear: 2024 }, + ]; + const spy = mockEduPoolStream(rows); - expect(res.status).toBe(200); - expect(res.headers['content-type']).toContain('application/x-ndjson'); - expect(res.body.complete).toBe(true); - const body = Buffer.concat(res.body.chunks).toString('utf8'); - const lines = body.split('\n').filter((l) => l.length > 0); - expect(lines).toHaveLength(3); - expect(JSON.parse(lines[0])).toEqual(rows[0]); - expect(JSON.parse(lines[2])).toEqual(rows[2]); - - expect(poolUseSpy).toHaveBeenCalledWith(partnerA.id, expect.any(Function)); - expect(capturedExecute).toBeDefined(); - expect(capturedExecute!.binds).toEqual([tenantA.code]); - expect(capturedExecute!.streamResult).toBe(true); - // Sanity-check that the query targets the EDU staging tables and uses the :1 bind. - expect(capturedExecute!.sqlText).toMatch( - /stg_ef3__student_education_organization_associations/ - ); - expect(capturedExecute!.sqlText).toMatch(/seoa\.tenant_code\s*=\s*:1/); + const res = await request(app.getHttpServer()) + .get(endpointA) + .set('Authorization', `Bearer ${tokenA}`) + .buffer(true) + .parse(streamParser); - poolUseSpy.mockRestore(); - }); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toContain('application/x-ndjson'); + expect(res.body.complete).toBe(true); + const body = Buffer.concat(res.body.chunks).toString('utf8'); + expect(body).toBe(rows.map((r) => JSON.stringify(r)).join('\n') + '\n'); - it('closes the response abruptly when the Snowflake row stream errors mid-flight', async () => { - await global.prisma.partner.update({ - where: { id: partnerA.id }, - data: { crossYearMatchingEnabled: true }, + spy.mockRestore(); }); - const eduPool = app.get(EduSnowflakePoolService); - const poolUseSpy = jest.spyOn(eduPool, 'use').mockImplementation(async (_partnerId, cb) => { - const errorStream = Readable.from( + it('closes the response abruptly when the Snowflake row stream errors mid-flight', async () => { + const spy = mockEduPoolStream( (async function* () { yield { studentUniqueId: '1' }; throw new Error('snowflake exploded mid-stream'); })() ); - const fakeConnection = { - execute: () => ({ streamRows: () => errorStream }), - }; - return cb(fakeConnection as never); - }); - // Consume chunks as they arrive. Pipeline destroys the response on - // stream error; headers (status + content-type) were already sent, so - // the client sees the first row, then the socket is closed before - // 'end' fires. - const res = await request(app.getHttpServer()) - .get(endpointA) - .set('Authorization', `Bearer ${tokenA}`) - .buffer(true) - .parse(streamParser); + // Consume chunks as they arrive. Pipeline destroys the response on + // stream error; headers (status + content-type) were already sent, so + // the client sees the first row, then the socket is closed before + // 'end' fires. + const res = await request(app.getHttpServer()) + .get(endpointA) + .set('Authorization', `Bearer ${tokenA}`) + .buffer(true) + .parse(streamParser); - expect(res.status).toBe(200); - expect(res.body.complete).toBe(false); - const body = Buffer.concat(res.body.chunks).toString('utf8'); - expect(body).toBe(JSON.stringify({ studentUniqueId: '1' }) + '\n'); - // No in-band sentinel / error marker — abrupt close, body simply truncates. - expect(body).not.toMatch(/error|exception/i); + expect(res.status).toBe(200); + expect(res.body.complete).toBe(false); + const body = Buffer.concat(res.body.chunks).toString('utf8'); + expect(body).toBe(JSON.stringify({ studentUniqueId: '1' }) + '\n'); + // No in-band sentinel / error marker — abrupt close, body simply truncates. + expect(body).not.toMatch(/error|exception/i); - poolUseSpy.mockRestore(); + spy.mockRestore(); + }); }); it('returns 500 when pool acquisition fails before any bytes are streamed', async () => { From 188f101a136b1b2ed50f3723bbcf377619652c4a Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Fri, 22 May 2026 14:47:22 -0500 Subject: [PATCH 43/46] test: tighten roster test assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mid-stream test: move the comment to point at the complete:false assertion as the truncation indicator (set by streamParser when 'close'/'error' fires before 'end'). Drop the body shape sanity check — the exact-bytes assertion already covers it. - Pool-acquisition-fails test: drop the content-type check. If we received a 500 status cleanly, supertest got a proper response; a torn socket would surface as a client-side error instead. Co-Authored-By: Claude Opus 4.7 --- app/api/integration/tests/earthbeam-api.spec.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/app/api/integration/tests/earthbeam-api.spec.ts b/app/api/integration/tests/earthbeam-api.spec.ts index 492bae4f..e9c7454f 100644 --- a/app/api/integration/tests/earthbeam-api.spec.ts +++ b/app/api/integration/tests/earthbeam-api.spec.ts @@ -419,22 +419,19 @@ describe('Earthbeam API', () => { })() ); - // Consume chunks as they arrive. Pipeline destroys the response on - // stream error; headers (status + content-type) were already sent, so - // the client sees the first row, then the socket is closed before - // 'end' fires. const res = await request(app.getHttpServer()) .get(endpointA) .set('Authorization', `Bearer ${tokenA}`) .buffer(true) .parse(streamParser); + // Pipeline destroys the response on stream error. Headers were + // already sent, so the status is 200, but the socket closes before + // 'end' fires — streamParser surfaces that as complete: false. expect(res.status).toBe(200); expect(res.body.complete).toBe(false); const body = Buffer.concat(res.body.chunks).toString('utf8'); expect(body).toBe(JSON.stringify({ studentUniqueId: '1' }) + '\n'); - // No in-band sentinel / error marker — abrupt close, body simply truncates. - expect(body).not.toMatch(/error|exception/i); spy.mockRestore(); }); @@ -456,8 +453,6 @@ describe('Earthbeam API', () => { .set('Authorization', `Bearer ${tokenA}`); expect(res.status).toBe(500); - // A clean JSON 500 — easier for the executor to diagnose than a torn socket. - expect(res.headers['content-type']).toContain('application/json'); poolUseSpy.mockRestore(); }); From a8ecc530c7817a87ec3f193733ed6bbfb9d8b61a Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Fri, 22 May 2026 15:50:36 -0500 Subject: [PATCH 44/46] guard roster transform against JSON.stringify throwing snowflake-sdk can return BigInt for high-precision NUMBER columns, and JSON.stringify throws on BigInt. A sync throw inside _transform escapes pipeline and crashes the process; routing the failure through cb(err) lets pipeline destroy the response cleanly, which the executor surfaces as the same abrupt-close it already handles. Co-Authored-By: Claude Opus 4.7 --- .../earthbeam/api/earthbeam-api.controller.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/app/api/src/earthbeam/api/earthbeam-api.controller.ts b/app/api/src/earthbeam/api/earthbeam-api.controller.ts index f56fbe61..f1638d14 100644 --- a/app/api/src/earthbeam/api/earthbeam-api.controller.ts +++ b/app/api/src/earthbeam/api/earthbeam-api.controller.ts @@ -140,7 +140,13 @@ export class EarthbeamApiController { writableObjectMode: true, transform(row, _enc, cb) { rowCount += 1; - cb(null, JSON.stringify(row) + '\n'); + // A sync throw here escapes pipeline and crashes the process; + // route stringify failures (e.g. BigInt) through cb(err) instead. + try { + cb(null, JSON.stringify(row) + '\n'); + } catch (err) { + cb(err as Error); + } }, }), res @@ -153,7 +159,9 @@ export class EarthbeamApiController { ); } catch (err) { this.logger.error( - `cross-year roster fetch failed for run ${runId}: ${err instanceof Error ? err.message : String(err)}` + `cross-year roster fetch failed for run ${runId}: ${ + err instanceof Error ? err.message : String(err) + }` ); // Abrupt close was a deliberate choice for *mid-stream* failures. If // headers haven't been sent yet (pool acquire / execute failed before @@ -164,9 +172,7 @@ export class EarthbeamApiController { res.destroy(err instanceof Error ? err : new Error(String(err))); } } else { - throw new InternalServerErrorException( - err instanceof Error ? err.message : String(err) - ); + throw new InternalServerErrorException(err instanceof Error ? err.message : String(err)); } } } From 907a3264ecf37644245ccf560825f5d1326ebd07 Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Tue, 26 May 2026 12:00:20 -0500 Subject: [PATCH 45/46] address review: pool timing + cross-year flow docs - bump EDU snowflake pool idleTimeoutMillis to 30 minutes so bursty partner usage within a half hour reuses warm connections; still well under Snowflake's 4h default programmatic-session timeout - simplify the failed-creation eviction in getOrCreatePool: there's no code path that races a concurrent insertion under the same key, so a plain delete suffices - add a Cross-Year Matching Flow subsection to AGENTS.md with a mermaid diagram describing the executor's staged ordering (both transforms, then ODS load, then API exposure) and clarifying that cross-year-matched rows reach external consumers via the Runway API, not the ODS - drop the "identifiers changing between school years" framing from the cross-year retry step; that's not the actual use case Co-Authored-By: Claude Opus 4.7 (1M context) --- AGENTS.md | 18 ++++++++++++++- .../api/edu-snowflake-pool.service.ts | 23 ++++++++----------- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4f78f35f..1c7c33c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -156,12 +156,28 @@ sequenceDiagram 4. **Roster fetch**: `lightbeam fetch` student roster from ODS, upload artifact to S3 5. **File download**: Download user-uploaded input files from S3 6. **Transform**: `earthmover run` against the ODS roster (with encoding detection + retry) -7. **Cross-year retry** (when `crossYearMatchAvailable` and the first pass produced unmatched students): GET `appUrls.roster` for the cross-year NDJSON roster, write to a `.jsonl` file, and re-run `earthmover` against it using the same ID type. Recovers students whose identifiers changed between school years. +7. **Cross-year retry** (when `crossYearMatchAvailable` and the first pass produced unmatched students): GET `appUrls.roster` for the cross-year NDJSON roster, write to a `.jsonl` file, and re-run `earthmover` against it using the same ID type. 8. **Load**: `lightbeam send` to Ed-Fi ODS 9. **Report**: POST summary, unmatched IDs, errors to app via callback URLs 10. **Output files**: POST output file path + `sentToOds` flag to `/output-files` callback; app validates path, lists S3, saves `run_output_file_set` 11. **Done**: POST status `{action: DONE, status: success|failure}` +### Cross-Year Matching Flow + +When cross-year matching runs, the executor progresses through each stage of processing for both rosters before moving on, to avoid mixed-status jobs (e.g., a file failing "insufficient matches" against the ODS roster but succeeding against the cross-year roster, when those matches really belonged in the ODS). + +```mermaid +flowchart TD + Input[Uploaded input rows] --> T1[earthmover: match + transform
against ODS roster] + T1 -->|on success, if crossYearMatchAvailable
and step 7 triggered| T2[earthmover: match + transform
against cross-year EDU roster] + T1 -->|on success, otherwise| Load + T2 -->|both transforms succeeded| Load[lightbeam send
ODS-matched rows → ODS] + Load -->|ODS load succeeded| App[POST results to Runway app
ODS-matched + cross-year-matched rows
exposed via API] + App -.fetched by.-> EDU[EDU / external API consumers] +``` + +Cross-year-matched rows are never sent to the ODS — they're only made available through the Runway app's API, which EDU and other external consumers query. + ### S3 Path Structure ``` diff --git a/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts b/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts index 64406506..998c0030 100644 --- a/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts +++ b/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts @@ -86,14 +86,8 @@ export class EduSnowflakePoolService implements OnModuleDestroy { const pool = this.createPool(partnerId); this.pools.set(partnerId, pool); - // Evict failed creations so subsequent requests can retry. Guard against - // racing with a concurrent insertion under the same key — only delete if - // the map still points at this entry. - pool.catch(() => { - if (this.pools.get(partnerId) === pool) { - this.pools.delete(partnerId); - } - }); + // Evict failed creations so subsequent requests can retry. + pool.catch(() => this.pools.delete(partnerId)); return pool; } @@ -125,17 +119,18 @@ export class EduSnowflakePoolService implements OnModuleDestroy { // is intentionally generous — JWT connect alone is ~20s, so this needs // headroom for cold-start + queue wait in a bursty 5+ concurrent run. // - // Evictor runs every 60s and closes connections idle for 60s. Combined - // with min: 0, this means a pool that hasn't served traffic for a minute - // drops to zero connections, avoiding stale-session failures on the next - // request (Snowflake server-side session timeout would otherwise kill - // the idle connection silently). + // Evictor runs every 60s and closes connections idle for 30 min. + // Combined with min: 0, an idle pool drops to zero connections, so + // bursty traffic within a half hour reuses warm connections while + // longer gaps release sockets. 30 min is well under Snowflake's 4h + // default programmatic-session timeout, so we won't hand out a + // server-side-expired connection. { min: 0, max: 4, acquireTimeoutMillis: 60_000, evictionRunIntervalMillis: 60_000, - idleTimeoutMillis: 60_000, + idleTimeoutMillis: 30 * 60_000, } ); From f07c7d92658bf4c721a588327b9c0df4ad604dfc Mon Sep 17 00:00:00 2001 From: Andy Kitson Date: Wed, 27 May 2026 17:21:26 -0500 Subject: [PATCH 46/46] raise EDU pool max to 20 and support optional warehouse/role - bump the per-partner EDU snowflake pool max from 4 to 20 to give bursty multi-tenant API workloads more concurrent connections before callers queue on acquire - add optional warehouse and role to EDU connection info (both the local-dev env path and the Secrets Manager path); when unset they're omitted from the connection options so Snowflake applies the user's defaults, letting the app override when needed - document EDU_SNOWFLAKE_WAREHOUSE / EDU_SNOWFLAKE_ROLE in .env.copyme Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/.env.copyme | 3 +++ app/api/src/config/app-config.service.ts | 20 +++++++++++++++++-- .../api/edu-snowflake-pool.service.ts | 5 ++++- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/app/api/.env.copyme b/app/api/.env.copyme index c6e24036..973033c0 100644 --- a/app/api/.env.copyme +++ b/app/api/.env.copyme @@ -36,6 +36,9 @@ EDU_SNOWFLAKE_ACCOUNT= EDU_SNOWFLAKE_DATABASE= EDU_SNOWFLAKE_SCHEMA= EDU_SNOWFLAKE_PRIVATE_KEY= +# Optional: leave blank to use the Snowflake user's defaults. +EDU_SNOWFLAKE_WAREHOUSE= +EDU_SNOWFLAKE_ROLE= OAUTH2_ISSUER=http://localhost:8080/realms/example OAUTH2_AUDIENCE=runway-local diff --git a/app/api/src/config/app-config.service.ts b/app/api/src/config/app-config.service.ts index 11a059c2..c76bd2b5 100644 --- a/app/api/src/config/app-config.service.ts +++ b/app/api/src/config/app-config.service.ts @@ -106,7 +106,16 @@ export class AppConfigService { * endpoint can surface a 5xx rather than masquerading as 409 "creds * missing"; only ResourceNotFoundException collapses to null. */ - async getEduConnectionInfo(partnerId: string) { + async getEduConnectionInfo(partnerId: string): Promise<{ + username: string; + account: string; + database: string; + schema: string; + privateKey: Buffer; + // Optional: when unset, Snowflake falls back to the user's defaults. + warehouse?: string; + role?: string; + } | null> { if (this.isDevEnvironment()) { const username = process.env.EDU_SNOWFLAKE_USERNAME; const account = process.env.EDU_SNOWFLAKE_ACCOUNT; @@ -122,6 +131,9 @@ export class AppConfigService { database, schema, privateKey: Buffer.from(privateKey, 'base64'), + // Optional: when unset, Snowflake falls back to the user's defaults. + warehouse: process.env.EDU_SNOWFLAKE_WAREHOUSE, + role: process.env.EDU_SNOWFLAKE_ROLE, }; } @@ -152,7 +164,8 @@ export class AppConfigService { if (typeof secret !== 'object') { return null; } - const { username, account, database, schema, privateKey } = secret; + const { username, account, database, schema, privateKey, warehouse, role } = + secret; if (!username || !account || !database || !schema || !privateKey) { return null; } @@ -162,6 +175,9 @@ export class AppConfigService { database, schema, privateKey: Buffer.from(privateKey, 'base64'), + // Optional: when unset, Snowflake falls back to the user's defaults. + warehouse, + role, }; } diff --git a/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts b/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts index 998c0030..4ddbfa69 100644 --- a/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts +++ b/app/api/src/earthbeam/api/edu-snowflake-pool.service.ts @@ -113,6 +113,9 @@ export class EduSnowflakePoolService implements OnModuleDestroy { schema: conn.schema, authenticator: 'SNOWFLAKE_JWT', privateKey: conn.privateKey.toString('utf-8'), + // Optional: omitted keys let Snowflake apply the user's defaults. + ...(conn.warehouse ? { warehouse: conn.warehouse } : {}), + ...(conn.role ? { role: conn.role } : {}), }, // Cap acquire waits so a saturated pool surfaces a clear timeout error // instead of hanging the request forever (generic-pool's default). 60s @@ -127,7 +130,7 @@ export class EduSnowflakePoolService implements OnModuleDestroy { // server-side-expired connection. { min: 0, - max: 4, + max: 20, acquireTimeoutMillis: 60_000, evictionRunIntervalMillis: 60_000, idleTimeoutMillis: 30 * 60_000,