From 7bcd3adab5b5c5a8a30c7a40b1b6312974e1e378 Mon Sep 17 00:00:00 2001 From: Tenari Date: Mon, 29 Jun 2026 17:15:52 -0500 Subject: [PATCH 01/13] wip --- .gitignore | 2 + src/common/audit_event.ts | 69 ++++++ src/common/audit_schema.ts | 81 +++++++ src/types/api.ts | 418 ++++++++++++++++++++----------------- src/types/audit_event.ts | 125 +++++++++++ src/types/audit_schema.ts | 67 ++++++ src/types/common.ts | 380 +++++++++++++++++---------------- src/types/index.ts | 42 ++-- 8 files changed, 785 insertions(+), 399 deletions(-) create mode 100644 src/common/audit_event.ts create mode 100644 src/common/audit_schema.ts create mode 100644 src/types/audit_event.ts create mode 100644 src/types/audit_schema.ts diff --git a/.gitignore b/.gitignore index 442d816..ec2c7cc 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,5 @@ apps/dashboard/common_types.tsbuildinfo *.log junit.xml .env.test.github-action + +*.swp diff --git a/src/common/audit_event.ts b/src/common/audit_event.ts new file mode 100644 index 0000000..54197e5 --- /dev/null +++ b/src/common/audit_event.ts @@ -0,0 +1,69 @@ +import { + AuditEvent, + AuditSchemaAction, + BearerTokenAPI, + CRUDXType, + RequestOptionsBase, + RequestOptionsGet, + RequestOptionsPaging, + RequestOptionsPost, +} from "../types"; + +export class AuditEvents { + constructor(private api: BearerTokenAPI) { + if (api.api !== "audit") { + throw new Error( + "The Audit Logs API only works with the Application TokenSetType", + ); + } + } + + /** + * Fetches a single AuditEvent by ID. + * @param event_id The UUID of the AuditEvent, or the 'action' string + * @returns The requested AuditEvent + */ + fetch( + event_id: string, + options: RequestOptionsGet = {}, + ): Promise { + return this.api.request(`/events/${event_id}`, options); + } + + /** + * Paginates through all classes that the user has access to. + * @param options Provide a `limit` for the max number of results + */ + async *list( + scope_id: string, + scope_type: string, + options: { limit?: number } = {}, + ): AsyncGenerator { + yield* this.api.paginate( + `/events/${scope_type}/${scope_id}`, + options, + ); + } + + /** + * Create the Event for a given 'action'. + * @param class_id The UUID of the class + * @returns The requested class + */ + create( + action: AuditEventAction, + data: any = {}, + validation_level: "strict" | "lax" = "lax", + options: RequestOptionsPost = {}, + ): Promise { + return this.api.request({ + url: `/schemas`, + method: "POST", + data: { + action, + validation_level, + data, + }, + }, options); + } +} diff --git a/src/common/audit_schema.ts b/src/common/audit_schema.ts new file mode 100644 index 0000000..ec83871 --- /dev/null +++ b/src/common/audit_schema.ts @@ -0,0 +1,81 @@ +import { + AuditSchema, + AuditSchemaAction, + BearerTokenAPI, + CRUDXType, + RequestOptionsBase, + RequestOptionsGet, + RequestOptionsPaging, + RequestOptionsPost, +} from "../types"; + +export class AuditSchemas { + constructor(private api: BearerTokenAPI) { + if (api.api !== "audit") { + throw new Error( + "The Audit Logs API only works with the Application TokenSetType", + ); + } + } + + /** + * Fetches a single AuditSchema by ID. + * @param schema_id_or_action The UUID of the AuditSchema, or the 'action' string + * @returns The requested AuditSchema + */ + fetch( + schema_id_or_action: string, + options: RequestOptionsGet = {}, + ): Promise { + return this.api.request(`/schemas/${schema_id_or_action}`, options); + } + + /** + * Update the existing Schema for a given 'action' + * @param action The 'action' string the Schema operates on + * @returns The new AuditSchema + */ + update( + action: string, + params: { + action_type?: CRUDXType; + validation_level?: "strict" | "lax"; + data?: object; + }, + options: RequestOptionsBase = {}, + ): Promise { + return this.api.request( + { + url: `/schemas`, + method: "PATCH", + data: { + action, + ...params, + }, + }, + options, + ); + } + + /** + * Create the Schema for a given 'action'. + * @param class_id The UUID of the class + * @returns The requested class + */ + create( + action: AuditSchemaAction, + data: any = {}, + validation_level: "strict" | "lax" = "lax", + options: RequestOptionsPost = {}, + ): Promise { + return this.api.request({ + url: `/schemas`, + method: "POST", + data: { + action, + validation_level, + data, + }, + }, options); + } +} diff --git a/src/types/api.ts b/src/types/api.ts index 575b0f0..a80a96b 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -1,228 +1,258 @@ -import { Edlink } from '..'; +import { Edlink } from ".."; // import { serialize } from '../common'; -import { deepDefaults } from '../utils'; -import { RequestOptions, TokenSet, TokenSetType } from './common'; +import { deepDefaults } from "../utils"; +import { RequestOptions, TokenSet, TokenSetType } from "./common"; export type RequestConfig = { - url: string; - method: string; - headers?: Record; - data?: any; -} + url: string; + method: string; + headers?: Record; + data?: any; +}; class EdlinkError extends Error { - status?: number; - code?: string; - errors: any[]; - - constructor({ message, status, code, errors = [] }: { message: string; status: number; code?: string; errors?: any[] }) { - super(message); - this.status = status; - this.code = code; - this.errors = errors; - } + status?: number; + code?: string; + errors: any[]; + + constructor( + { message, status, code, errors = [] }: { + message: string; + status: number; + code?: string; + errors?: any[]; + }, + ) { + super(message); + this.status = status; + this.code = code; + this.errors = errors; + } } export class BearerTokenAPI { - private token_set: TokenSet; - private version: number; - private api: 'graph' | 'my'; - public edlink: Edlink; - - constructor(edlink: Edlink, token_set: TokenSet) { - // Assign config - this.token_set = token_set; - this.version = edlink.version; - this.api = token_set.type === TokenSetType.Integration ? 'graph' : 'my'; - this.edlink = edlink; + private token_set: TokenSet; + private version: number; + public api: "graph" | "my" | "audit"; + public edlink: Edlink; + + constructor(edlink: Edlink, token_set: TokenSet) { + // Assign config + this.token_set = token_set; + this.version = edlink.version; + this.api = "graph"; // default for TokenSetType.Integration + if (token_set.type === TokenSetType.Application) { + this.api = "audit"; + } else if (token_set.type === TokenSetType.Person) { + this.api = "my"; } + this.edlink = edlink; + } - async request( - config: string | RequestConfig, - options: RequestOptions = {}, - raw = false - ): Promise { - const defaults: Record = { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - } - }; - let req: Request; - let url: string; - // Format options into request params - const formatted_options = this.formatParams(options); - // If config is a string, set the url - if (typeof config === 'string') { - url = config; - } else { - url = config.url; - } + async request( + config: string | RequestConfig, + options: RequestOptions = {}, + raw = false, + ): Promise { + const defaults: Record = { + method: "GET", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + }; + let req: Request; + let url: string; + // Format options into request params + const formatted_options = this.formatParams(options); + // If config is a string, set the url + if (typeof config === "string") { + url = config; + } else { + url = config.url; + } - // Set url - const formattedUrl = new URL(url.startsWith('http') + // Set url + const formattedUrl = new URL( + url.startsWith("http") ? url - : `https://ed.link/api/v${this.version}/${this.api}${url}`); + : `https://ed.link/api/v${this.version}/${this.api}${url}`, + ); + + // Add formatted options to the URL + for (const [key, value] of Object.entries(formatted_options)) { + formattedUrl.searchParams.append(key, value); + } + + // Set url + defaults.url = formattedUrl.toString(); + // Set access token + defaults.headers["Authorization"] = `Bearer ${this.token_set.access_token}`; + + if (typeof config === "string") { + req = new Request(formattedUrl, defaults); + } else { + // Set missing defaults + if (config.data !== undefined && config.data !== null) { + defaults.body = JSON.stringify(config.data); + defaults.headers["Content-Type"] = "application/json"; + } + // Merge defaults + deepDefaults(config, defaults); + // Set url + config.url = formattedUrl.toString(); + // Create request + req = new Request(config.url, config); + } + + // Check if the token needs to be refreshed if this is a person token set. + if ( + this.token_set.type === TokenSetType.Person && + this.token_set.refresh_token && this.requiresTokenRefresh() + ) { + // do the token refresh + const { access_token, refresh_token } = await this.edlink.auth.refresh( + this.token_set.refresh_token!, + ) + .catch(() => { + throw new EdlinkError({ + message: "Failed to refresh token.", + status: 400, + code: "BAD_REQUEST", + }); + }); + + // update the tokenset + this.token_set.access_token = access_token; + this.token_set.refresh_token = refresh_token; + this.token_set.expiration_date = new Date(Date.now() + 3600 * 1000); + } + // Make request + const response = await fetch(req); + const data = await response.json(); - // Add formatted options to the URL - for (const [key, value] of Object.entries(formatted_options)) { - formattedUrl.searchParams.append(key, value); + if (data.$warnings) { + if (this.edlink.log_level === "debug") { + for (const warning of data.$warnings) { + console.warn( + `Edlink API Warning: ${warning.code} ${warning.message}`, + ); } + } + } + + if (data.$errors) { + if (this.edlink.log_level === "debug") { + for (const error of data.$errors) { + console.warn(`Edlink API Error: ${error.code} ${error.message}`); + } + } + throw new EdlinkError({ + ...data.$errors[0], + status: response.status, + errors: data.$errors.slice(1), + }); + } - // Set url - defaults.url = formattedUrl.toString(); - // Set access token - defaults.headers['Authorization'] = `Bearer ${this.token_set.access_token}`; + return raw ? data : data.$data; + } - if (typeof config === 'string') { - req = new Request(formattedUrl, defaults); + async *paginate( + config: string | RequestConfig, + options: Record = {}, + until?: (item: T) => boolean, + formatter?: (raw: any) => T, + ): AsyncGenerator { + let next = null; + let data: T[] = []; + let remaining = options.limit; + + do { + if (next) { + if (typeof config === "string") { + config = next; } else { - // Set missing defaults - if (config.data !== undefined && config.data !== null) { - defaults.body = JSON.stringify(config.data); - defaults.headers['Content-Type'] = 'application/json'; - } - // Merge defaults - deepDefaults(config, defaults); - // Set url - config.url = formattedUrl.toString(); - // Create request - req = new Request(config.url, config); + config.url = next; } + } - // Check if the token needs to be refreshed if this is a person token set. - if (this.token_set.type === TokenSetType.Person && this.token_set.refresh_token && this.requiresTokenRefresh()) { - // do the token refresh - const { access_token, refresh_token } = await this.edlink.auth.refresh(this.token_set.refresh_token!) - .catch(() => { - throw new EdlinkError({ - message: 'Failed to refresh token.', - status: 400, - code: 'BAD_REQUEST' - }); - }); - - // update the tokenset - this.token_set.access_token = access_token; - this.token_set.refresh_token = refresh_token; - this.token_set.expiration_date = new Date(Date.now() + 3600 * 1000); + const response: { $data: T[]; $next?: string } = await this.request( + config, + options, + true, + ); + next = response.$next; + data = response.$data; + for (const item of data) { + const formatted = formatter ? formatter(item) : item; + if ( + (until && until(formatted)) || + (remaining !== undefined && remaining <= 0) + ) { + return; } - // Make request - const response = await fetch(req); - const data = await response.json(); - - if (data.$warnings) { - if (this.edlink.log_level === 'debug') { - for (const warning of data.$warnings) { - console.warn(`Edlink API Warning: ${warning.code} ${warning.message}`); - } - } + yield formatted; + if (remaining) { + remaining--; } + } + } while (next && (remaining === undefined || remaining > 0)); + } - if (data.$errors) { - if (this.edlink.log_level === 'debug') { - for (const error of data.$errors) { - console.warn(`Edlink API Error: ${error.code} ${error.message}`); - } - } - throw new EdlinkError({ - ...data.$errors[0], - status: response.status, - errors: data.$errors.slice(1) - }); - } - - return raw ? data : data.$data; + private requiresTokenRefresh(tokens: TokenSet = this.token_set): boolean { + if (tokens.type === TokenSetType.Integration) { + return false; } - async *paginate( - config: string | RequestConfig, - options: Record = {}, - until?: (item: T) => boolean, - formatter?: (raw: any) => T - ): AsyncGenerator { - let next = null; - let data: T[] = []; - let remaining = options.limit; - - do { - if (next) { - if (typeof config === 'string') { - config = next; - } else { - config.url = next; - } - } - - const response: { $data: T[]; $next?: string } = await this.request( - config, - options, - true - ); - next = response.$next; - data = response.$data; - for (const item of data) { - const formatted = formatter ? formatter(item) : item; - if ((until && until(formatted)) || (remaining !== undefined && remaining <= 0)) { - return; - } - yield formatted; - if (remaining) { - remaining--; - } - } - } while (next && (remaining === undefined || remaining > 0)); + if (tokens.expiration_date == null || tokens.access_token == null) { + return true; } - private requiresTokenRefresh(tokens: TokenSet = this.token_set): boolean { - if (tokens.type === TokenSetType.Integration) { - return false; - } + const current_date = new Date(); + const expiration_date = new Date(tokens.expiration_date); - if (tokens.expiration_date == null || tokens.access_token == null) { - return true; - } + // Set the expiration date to 5 minutes earlier for safety. + expiration_date.setMinutes(expiration_date.getMinutes() - 5); - const current_date = new Date(); - const expiration_date = new Date(tokens.expiration_date); + return current_date > expiration_date; + } - // Set the expiration date to 5 minutes earlier for safety. - expiration_date.setMinutes(expiration_date.getMinutes() - 5); + public formatParams(params: RequestOptions): Record { + const formatted: { + $idempotency?: string; + $filter?: string; + $expand?: string; + $properties?: string; + } & Record = {}; - return current_date > expiration_date; - } - - public formatParams(params: RequestOptions): Record { - const formatted: { - $idempotency?: string; - $filter?: string; - $expand?: string; - $properties?: string; - } & Record = {}; - - const mappings: Record any }> = { - idempotency: '$idempotency', - filter: { prop: '$filter', mod: JSON.stringify }, - properties: { prop: '$properties', mod: (value: string[]) => value.join(',') }, - expand: { prop: '$expand', mod: (value: string[]) => value.join(',') }, - } + const mappings: Record< + string, + string | { prop: string; mod: (value: any) => any } + > = { + idempotency: "$idempotency", + filter: { prop: "$filter", mod: JSON.stringify }, + properties: { + prop: "$properties", + mod: (value: string[]) => value.join(","), + }, + expand: { prop: "$expand", mod: (value: string[]) => value.join(",") }, + }; - for (const [key, value] of Object.entries(params)) { - const mapping = mappings[key]; - - if (mapping) { - if (typeof mapping === 'string') { - formatted[mapping] = value; - } else { - const { prop, mod } = mapping; - formatted[prop] = mod(value); - } - } else { - formatted[key] = value; - } - } + for (const [key, value] of Object.entries(params)) { + const mapping = mappings[key]; - return formatted; + if (mapping) { + if (typeof mapping === "string") { + formatted[mapping] = value; + } else { + const { prop, mod } = mapping; + formatted[prop] = mod(value); + } + } else { + formatted[key] = value; + } } + + return formatted; + } } diff --git a/src/types/audit_event.ts b/src/types/audit_event.ts new file mode 100644 index 0000000..25cbae8 --- /dev/null +++ b/src/types/audit_event.ts @@ -0,0 +1,125 @@ +import { CRUDXType } from "."; +type UUID = `${string}-${string}-${string}-${string}-${string}`; + +export enum ScopeType { + Institution = "institution", + Integration = "integration", +} + +export interface AuditIdentifier { + value: string; + issuer: string; +} + +export interface Actor { + type: "person" | "system" | "external"; + identifiers: AuditIdentifier[]; + details?: object; +} + +export interface Target { + type: string; + identifiers: AuditIdentifier[]; + details?: object; +} + +export interface Scope { + id: UUID; + type: ScopeType; +} + +export interface SchemaID { + id: string; + version: string; +} + +export interface Context { + source?: "client" | "server"; + http_method?: + | "GET" + | "HEAD" + | "POST" + | "PUT" + | "DELETE" + | "CONNECT" + | "OPTIONS" + | "TRACE" + | "PATCH"; + http_status?: number; + path?: string; + ip?: string; + query?: string; + user_agent?: string; + hostname?: string; + os?: string; + environment?: string; + trigger?: "person" | "system" | "external"; + deployment_id?: string; +} + +/** + * @export + * @interface AuditEvent + */ +export interface AuditEvent { + /** + * @type {string} + * @memberof AuditEvent + */ + id: string; + /** + * @type {Date} + * @memberof AuditEvent + */ + created_date: string; + /** + * @type {SchemaID} + * @memberof AuditEvent + */ + schema: SchemaID; + /** + * @type {Actor} + * @memberof AuditEvent + */ + actor: Actor; + /** + * @type {string} + * @memberof AuditEvent + */ + version: string; + /** + * @type {string} + * @memberof AuditEvent + */ + validation_level: "strict" | "lax"; + /** + * @type {string} + * @memberof AuditEvent + */ + action: string; + /** + * @type {CRUDXType} + * @memberof AuditEvent + */ + action_type: CRUDXType; + /** + * @type {Array} + * @memberof AuditEvent + */ + targets: Target[]; + /** + * @type {Scope} + * @memberof AuditEvent + */ + scope: Scope; + /** + * @type {Context} + * @memberof AuditEvent + */ + context: Context; + /** + * @type {any} + * @memberof AuditEvent + */ + data: any; +} diff --git a/src/types/audit_schema.ts b/src/types/audit_schema.ts new file mode 100644 index 0000000..6176659 --- /dev/null +++ b/src/types/audit_schema.ts @@ -0,0 +1,67 @@ +export interface AuditSchemaAction { + /** + * @type {string} + * @memberof AuditSchemaAction + */ + id: string; + /** + * @type {CRUDXType} + * @memberof AuditSchemaAction + */ + type: CRUDXType; +} + +/** + * @export + * @interface AuditSchema + */ +export interface AuditSchema { + /** + * @type {string} + * @memberof AuditSchema + */ + id: string; + /** + * @type {Date} + * @memberof AuditSchema + */ + created_date: string; + /** + * @type {Date} + * @memberof AuditSchema + */ + updated_date: string; + /** + * @type {any} + * @memberof AuditSchema + */ + data: any; + /** + * @type {string} + * @memberof AuditSchema + */ + version: string; + /** + * @type {string} + * @memberof AuditSchema + */ + validation_level: "strict" | "lax"; + /** + * [Action Type](https://ed.link/docs/api/v2.0/audit-log-schemas/schema-get) + * @type {AuditSchemaAction} + * @memberof AuditSchema + */ + action: AuditSchemaAction; +} + +/** + * @export + * @enum {string} + */ +export enum CRUDXType { + Create = "create", + Read = "read", + Update = "update", + Delete = "delete", + Other = "other", +} diff --git a/src/types/common.ts b/src/types/common.ts index 878e4cd..7a640c1 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -3,78 +3,76 @@ * @enum {string} */ export enum Subject { - // CEDS Subjects - LanguageArts = 'CEDS.01', // EnglishLanguageAndLiterature in CEDS - Mathematics = 'CEDS.02', - LifeAndPhysicalSciences = 'CEDS.03', - SocialSciencesAndHistory = 'CEDS.04', - VisualAndPerformingArts = 'CEDS.05', - ReligiousEducationAndTheology = 'CEDS.07', - PhysicalHealthAndSafetyEducation = 'CEDS.08', - MilitaryScience = 'CEDS.09', - InformationTechnology = 'CEDS.10', - CommunicationAndAudioVisualTechnology = 'CEDS.11', - BusinessAndMarketing = 'CEDS.12', - Manufacturing = 'CEDS.13', - HealthCareSciences = 'CEDS.14', - PublicProtectiveAndGovernmentService = 'CEDS.15', - HospitalityAndTourism = 'CEDS.16', - ArchitectureAndConstruction = 'CEDS.17', - AgricultureFoodAndNaturalResources = 'CEDS.18', - HumanServices = 'CEDS.19', - TransportationDistributionAndLogistics = 'CEDS.20', - EngineeringAndTechnology = 'CEDS.21', - Miscellaneous = 'CEDS.22', - NonSubjectSpecific = 'CEDS.23', - WorldLanguages = 'CEDS.24', + // CEDS Subjects + LanguageArts = "CEDS.01", // EnglishLanguageAndLiterature in CEDS + Mathematics = "CEDS.02", + LifeAndPhysicalSciences = "CEDS.03", + SocialSciencesAndHistory = "CEDS.04", + VisualAndPerformingArts = "CEDS.05", + ReligiousEducationAndTheology = "CEDS.07", + PhysicalHealthAndSafetyEducation = "CEDS.08", + MilitaryScience = "CEDS.09", + InformationTechnology = "CEDS.10", + CommunicationAndAudioVisualTechnology = "CEDS.11", + BusinessAndMarketing = "CEDS.12", + Manufacturing = "CEDS.13", + HealthCareSciences = "CEDS.14", + PublicProtectiveAndGovernmentService = "CEDS.15", + HospitalityAndTourism = "CEDS.16", + ArchitectureAndConstruction = "CEDS.17", + AgricultureFoodAndNaturalResources = "CEDS.18", + HumanServices = "CEDS.19", + TransportationDistributionAndLogistics = "CEDS.20", + EngineeringAndTechnology = "CEDS.21", + Miscellaneous = "CEDS.22", + NonSubjectSpecific = "CEDS.23", + WorldLanguages = "CEDS.24", - // Edlink Subjects - SpecialEducation = 'EL.01', - ProfessionalDevelopment = 'EL.02' + // Edlink Subjects + SpecialEducation = "EL.01", + ProfessionalDevelopment = "EL.02", } /** * @export * @enum {string} */ export enum GradeLevel { - Birth = 'Birth', - Prenatal = 'Prenatal', - InfantToddler = 'IT', - Preschool = 'PR', - Prekindergarten = 'PK', - TransitionalKindergarten = 'TK', - Kindergarten = 'KG', - FirstGrade = '01', - SecondGrade = '02', - ThirdGrade = '03', - FourthGrade = '04', - FifthGrade = '05', - SixthGrade = '06', - SeventhGrade = '07', - EighthGrade = '08', - NinthGrade = '09', - TenthGrade = '10', - EleventhGrade = '11', - TwelfthGrade = '12', - GradeThirteen = '13', - Postsecondary = 'PS', - Ungraded = 'UG', - Other = 'Other' + Birth = "Birth", + Prenatal = "Prenatal", + InfantToddler = "IT", + Preschool = "PR", + Prekindergarten = "PK", + TransitionalKindergarten = "TK", + Kindergarten = "KG", + FirstGrade = "01", + SecondGrade = "02", + ThirdGrade = "03", + FourthGrade = "04", + FifthGrade = "05", + SixthGrade = "06", + SeventhGrade = "07", + EighthGrade = "08", + NinthGrade = "09", + TenthGrade = "10", + EleventhGrade = "11", + TwelfthGrade = "12", + GradeThirteen = "13", + Postsecondary = "PS", + Ungraded = "UG", + Other = "Other", } export interface Identifier { - /** - * - * @type {string} - * @memberof Identifier - */ - type: string; - /** - * - * @type {string} - * @memberof Identifier - */ - value: string; + /** + * @type {string} + * @memberof Identifier + */ + type: string; + /** + * @type {string} + * @memberof Identifier + */ + value: string; } // @@ -85,36 +83,36 @@ export interface Identifier { * @enum {string} */ export enum IdentifierType { - SIS = 'sis_id', - State = 'state_id', - School = 'school_id', - Username = 'username', - Email = 'email', - LDAP = 'ldap', - LTI = 'lti', + SIS = "sis_id", + State = "state_id", + School = "school_id", + Username = "username", + Email = "email", + LDAP = "ldap", + LTI = "lti", - Edlink = 'edlink_id', + Edlink = "edlink_id", - NCES = 'nces_id', - MDR = 'mdr_id', - IPEDS = 'ipeds_id', + NCES = "nces_id", + MDR = "mdr_id", + IPEDS = "ipeds_id", - Aeries = 'aeries_id', - Blackbaud = 'blackbaud_id', - Blackboard = 'blackboard_id', - Brightspace = 'brightspace_id', - Bromcom = 'bromcom_id', - Canvas = 'canvas_id', - CanvasLti = 'canvas_lti_id', - Clever = 'clever_id', - Google = 'google_id', - Illuminate = 'illuminate_id', - Microsoft = 'microsoft_id', - Moodle = 'moodle_id', - OneRoster = 'oneroster_id', - PowerSchool = 'powerschool_id', - Schoology = 'schoology_id', - Skyward = 'skyward_id' + Aeries = "aeries_id", + Blackbaud = "blackbaud_id", + Blackboard = "blackboard_id", + Brightspace = "brightspace_id", + Bromcom = "bromcom_id", + Canvas = "canvas_id", + CanvasLti = "canvas_lti_id", + Clever = "clever_id", + Google = "google_id", + Illuminate = "illuminate_id", + Microsoft = "microsoft_id", + Moodle = "moodle_id", + OneRoster = "oneroster_id", + PowerSchool = "powerschool_id", + Schoology = "schoology_id", + Skyward = "skyward_id", } /** @@ -122,9 +120,9 @@ export enum IdentifierType { * @enum {string} */ export enum Gender { - Male = 'male', - Female = 'female', - Other = 'other' + Male = "male", + Female = "female", + Other = "other", } /** @@ -132,11 +130,12 @@ export enum Gender { * @enum {string} */ export enum Race { - AmericanIndianOrAlaskaNative = 'american-indian-or-alaska-native', - Asian = 'asian', - BlackOrAfricanAmerican = 'black-or-african-american', - NativeHawaiianOrOtherPacificIslander = 'native-hawaiian-or-other-pacific-islander', - White = 'white' + AmericanIndianOrAlaskaNative = "american-indian-or-alaska-native", + Asian = "asian", + BlackOrAfricanAmerican = "black-or-african-american", + NativeHawaiianOrOtherPacificIslander = + "native-hawaiian-or-other-pacific-islander", + White = "white", } /** @@ -144,133 +143,144 @@ export enum Race { * @enum {string} */ export enum Role { - Student = 'student', - DistrictAdministrator = 'district-administrator', - Administrator = 'administrator', - Teacher = 'teacher', - TeachingAssistant = 'ta', - Staff = 'staff', - Aide = 'aide', - Observer = 'observer', - Parent = 'parent', - Guardian = 'guardian', - Designer = 'designer', - Member = 'member' + Student = "student", + DistrictAdministrator = "district-administrator", + Administrator = "administrator", + Teacher = "teacher", + TeachingAssistant = "ta", + Staff = "staff", + Aide = "aide", + Observer = "observer", + Parent = "parent", + Guardian = "guardian", + Designer = "designer", + Member = "member", } export enum TokenSetType { - Integration = 'integration', - Person = 'person', - Application = 'application' -}; + Integration = "integration", + Person = "person", + Application = "application", +} export type BaseTokenSet = { - type: TokenSetType; - token_type?: 'Bearer'; - access_token: string; - refresh_token?: string; - context?: Record; + type: TokenSetType; + token_type?: "Bearer"; + access_token: string; + refresh_token?: string; + context?: Record; }; /** * Important note: If you do not provide a refresh token the SDK will not be able to refresh the token for you and requests made an hour after the initial token exchange will fail */ export type PersonTokenSet = BaseTokenSet & { - type: TokenSetType.Person; - access_token?: string; - refresh_token?: string; - expires_in?: Date; - expiration_date?: Date; + type: TokenSetType.Person; + access_token?: string; + refresh_token?: string; + expires_in?: Date; + expiration_date?: Date; }; export type IntegrationTokenSet = BaseTokenSet & { - type: TokenSetType.Integration; - access_token: string; + type: TokenSetType.Integration; + access_token: string; +}; + +export type ApplicationTokenSet = BaseTokenSet & { + type: TokenSetType.Application; + access_token: string; }; -export type TokenSet = PersonTokenSet | IntegrationTokenSet; +export type TokenSet = + | PersonTokenSet + | IntegrationTokenSet + | ApplicationTokenSet; export type RequestOptionsPaging = { - limit?: number; - filter?: Record; + limit?: number; + filter?: Record; } & RequestOptionsGet; export type RequestOptionsGet = { - expand?: string[]; + expand?: string[]; } & RequestOptionsBase; export type RequestOptionsPost = { - idempotency?: string; + idempotency?: string; } & RequestOptionsBase; export type RequestOptionsBase = { - properties?: string[]; + properties?: string[]; }; -export type RequestOptions = RequestOptionsPaging | RequestOptionsGet | RequestOptionsBase; +export type RequestOptions = + | RequestOptionsPaging + | RequestOptionsGet + | RequestOptionsBase; export enum ProductState { - Active = 'active', - Inactive = 'inactive', - Upcoming = 'upcoming', - Development = 'development', - Sunsetting = 'sunsetting', - Deprecated = 'deprecated' -}; + Active = "active", + Inactive = "inactive", + Upcoming = "upcoming", + Development = "development", + Sunsetting = "sunsetting", + Deprecated = "deprecated", +} export enum IntegrationState { - Requested = 'requested', - Inactive = 'inactive', - Active = 'active', - Error = 'error', - Disabled = 'disabled', - Paused = 'paused', - Pending = 'pending', - Scheduled = 'scheduled' -}; + Requested = "requested", + Inactive = "inactive", + Active = "active", + Error = "error", + Disabled = "disabled", + Paused = "paused", + Pending = "pending", + Scheduled = "scheduled", +} export type Integration = { - state: IntegrationState; - id: string; - created_date: string; - updated_date: string; - permissions: string[]; - scope: string; - start_date: string; - end_date: string; - locked: boolean; - application_id: string; - source_id: string; - destination_id: string; - region_id: string; + state: IntegrationState; + id: string; + created_date: string; + updated_date: string; + permissions: string[]; + scope: string; + start_date: string; + end_date: string; + locked: boolean; + application_id: string; + source_id: string; + destination_id: string; + region_id: string; }; export enum SourceState { - Inactive = 'inactive', // Waiting to be validated (set when configuration changes) - Active = 'active', // First sync was successful, scheduled to sync regularly - Error = 'error', // Validation or sync failed - Disabled = 'disabled', // Disabled manually - Destroyed = 'destroyed' // Basically just waiting for cleanup. -}; + Inactive = "inactive", // Waiting to be validated (set when configuration changes) + Active = "active", // First sync was successful, scheduled to sync regularly + Error = "error", // Validation or sync failed + Disabled = "disabled", // Disabled manually + Destroyed = "destroyed", // Basically just waiting for cleanup. +} export type Source = { - state: SourceState; + state: SourceState; + id: string; + created_date: string; + updated_date: string; + name: string; + sync_interval: number; + provider_id: string; + properties: Record; + team_id: string; + provider: { + active: boolean; + requires_administrator_login: boolean; + requires_administrator_consent: boolean; + requires_remote_configuration: boolean; + allows_data_sync: boolean; id: string; - created_date: string; - updated_date: string; name: string; - sync_interval: number; - provider_id: string; - properties: Record; - team_id: string; - provider: { - active: boolean; - requires_administrator_login: boolean; - requires_administrator_consent: boolean; - requires_remote_configuration: boolean; - allows_data_sync: boolean; - id: string; - name: string; - application: string; - } -}; \ No newline at end of file + application: string; + }; +}; diff --git a/src/types/index.ts b/src/types/index.ts index 2942cfe..1b1fa13 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,20 +1,22 @@ -export * from './api'; -export * from './common'; -export * from './address'; -export * from './agent'; -export * from './course'; -export * from './class'; -export * from './demographics'; -export * from './district'; -export * from './enrollment'; -export * from './event'; -export * from './person'; -export * from './school'; -export * from './section'; -export * from './session'; -export * from './assignment'; -export * from './category'; -export * from './submission'; -export * from './attachment'; -export * from './license'; -export * from './product'; \ No newline at end of file +export * from "./api"; +export * from "./audit_schema"; +export * from "./audit_event"; +export * from "./common"; +export * from "./address"; +export * from "./agent"; +export * from "./course"; +export * from "./class"; +export * from "./demographics"; +export * from "./district"; +export * from "./enrollment"; +export * from "./event"; +export * from "./person"; +export * from "./school"; +export * from "./section"; +export * from "./session"; +export * from "./assignment"; +export * from "./category"; +export * from "./submission"; +export * from "./attachment"; +export * from "./license"; +export * from "./product"; From 7a414d8785893b7660093854e272a921ac02c9fa Mon Sep 17 00:00:00 2001 From: Tenari Date: Mon, 29 Jun 2026 17:38:41 -0500 Subject: [PATCH 02/13] event v1 --- src/common/audit_event.ts | 100 ++++++++------------- src/types/audit_event.ts | 183 ++++++++++++++++++-------------------- 2 files changed, 125 insertions(+), 158 deletions(-) diff --git a/src/common/audit_event.ts b/src/common/audit_event.ts index 54197e5..bed6b8d 100644 --- a/src/common/audit_event.ts +++ b/src/common/audit_event.ts @@ -1,69 +1,45 @@ -import { - AuditEvent, - AuditSchemaAction, - BearerTokenAPI, - CRUDXType, - RequestOptionsBase, - RequestOptionsGet, - RequestOptionsPaging, - RequestOptionsPost, -} from "../types"; +import { Actor, AuditEvent, BearerTokenAPI, Context, RequestOptionsGet, RequestOptionsPaging, RequestOptionsPost, Scope, Target } from '../types'; export class AuditEvents { - constructor(private api: BearerTokenAPI) { - if (api.api !== "audit") { - throw new Error( - "The Audit Logs API only works with the Application TokenSetType", - ); + constructor(private api: BearerTokenAPI) { + if (api.api !== 'audit') { + throw new Error('The Audit Logs API only works with the Application TokenSetType'); + } } - } - /** - * Fetches a single AuditEvent by ID. - * @param event_id The UUID of the AuditEvent, or the 'action' string - * @returns The requested AuditEvent - */ - fetch( - event_id: string, - options: RequestOptionsGet = {}, - ): Promise { - return this.api.request(`/events/${event_id}`, options); - } + /** + * Paginates through all Events within a given scope. + * @param scope_type The type of scope, either `integration` or `institution` + * @param scope_id The UUID of the scope + * @param options Provide a `limit` for the max number of results + */ + async *list(scope_type: string, scope_id: string, options: RequestOptionsPaging = {}): AsyncGenerator { + yield* this.api.paginate(`/events/${scope_type}/${scope_id}`, options); + } - /** - * Paginates through all classes that the user has access to. - * @param options Provide a `limit` for the max number of results - */ - async *list( - scope_id: string, - scope_type: string, - options: { limit?: number } = {}, - ): AsyncGenerator { - yield* this.api.paginate( - `/events/${scope_type}/${scope_id}`, - options, - ); - } + /** + * Fetches a single Event by ID. + * @param event_id The UUID of the Event + * @returns The requested Event + */ + fetch(event_id: string, options: RequestOptionsGet = {}): Promise { + return this.api.request(`/events/${event_id}`, options); + } - /** - * Create the Event for a given 'action'. - * @param class_id The UUID of the class - * @returns The requested class - */ - create( - action: AuditEventAction, - data: any = {}, - validation_level: "strict" | "lax" = "lax", - options: RequestOptionsPost = {}, - ): Promise { - return this.api.request({ - url: `/schemas`, - method: "POST", - data: { - action, - validation_level, - data, - }, - }, options); - } + /** + * Records a new Event. + * @param event The Event body to record + * @param options Optional request options + * @returns The recorded Event's assigned ID + */ + create( + event: { actor: Actor; action: string; targets: Target[]; scope: Scope; context?: Context; data?: any }, + options: RequestOptionsPost = {}, + ): Promise> { + return this.api.request({ + url: '/events', + method: 'POST', + data: event, + }, options); + } } diff --git a/src/types/audit_event.ts b/src/types/audit_event.ts index 25cbae8..38c015c 100644 --- a/src/types/audit_event.ts +++ b/src/types/audit_event.ts @@ -2,59 +2,50 @@ import { CRUDXType } from "."; type UUID = `${string}-${string}-${string}-${string}-${string}`; export enum ScopeType { - Institution = "institution", - Integration = "integration", + Institution = "institution", + Integration = "integration", } export interface AuditIdentifier { - value: string; - issuer: string; + value: string; + issuer: string; } export interface Actor { - type: "person" | "system" | "external"; - identifiers: AuditIdentifier[]; - details?: object; + type: "person" | "system" | "external"; + identifiers: AuditIdentifier[]; + details?: object; } export interface Target { - type: string; - identifiers: AuditIdentifier[]; - details?: object; + type: string; + identifiers: AuditIdentifier[]; + details?: object; } export interface Scope { - id: UUID; - type: ScopeType; + id: UUID; + type: ScopeType; } -export interface SchemaID { - id: string; - version: string; +export interface SchemaRef { + id: string; + version: string; } export interface Context { - source?: "client" | "server"; - http_method?: - | "GET" - | "HEAD" - | "POST" - | "PUT" - | "DELETE" - | "CONNECT" - | "OPTIONS" - | "TRACE" - | "PATCH"; - http_status?: number; - path?: string; - ip?: string; - query?: string; - user_agent?: string; - hostname?: string; - os?: string; - environment?: string; - trigger?: "person" | "system" | "external"; - deployment_id?: string; + source?: "client" | "server"; + http_method?: "GET" | "HEAD" | "POST" | "PUT" | "DELETE" | "CONNECT" | "OPTIONS" | "TRACE" | "PATCH"; + http_status?: number; + path?: string; + ip?: string; + query?: string; + user_agent?: string; + hostname?: string; + os?: string; + environment?: string; + trigger?: "person" | "system" | "external"; + deployment_id?: string; } /** @@ -62,64 +53,64 @@ export interface Context { * @interface AuditEvent */ export interface AuditEvent { - /** - * @type {string} - * @memberof AuditEvent - */ - id: string; - /** - * @type {Date} - * @memberof AuditEvent - */ - created_date: string; - /** - * @type {SchemaID} - * @memberof AuditEvent - */ - schema: SchemaID; - /** - * @type {Actor} - * @memberof AuditEvent - */ - actor: Actor; - /** - * @type {string} - * @memberof AuditEvent - */ - version: string; - /** - * @type {string} - * @memberof AuditEvent - */ - validation_level: "strict" | "lax"; - /** - * @type {string} - * @memberof AuditEvent - */ - action: string; - /** - * @type {CRUDXType} - * @memberof AuditEvent - */ - action_type: CRUDXType; - /** - * @type {Array} - * @memberof AuditEvent - */ - targets: Target[]; - /** - * @type {Scope} - * @memberof AuditEvent - */ - scope: Scope; - /** - * @type {Context} - * @memberof AuditEvent - */ - context: Context; - /** - * @type {any} - * @memberof AuditEvent - */ - data: any; + /** + * Read-only. Assigned by Edlink when the Event is recorded. + * @type {string} + * @memberof AuditEvent + */ + id?: string; + /** + * Read-only. The timestamp at which Edlink received the Event. + * @type {string} + * @memberof AuditEvent + */ + created_date?: string; + /** + * Read-only. The Schema that validated this Event, if any. + * @type {SchemaRef} + * @memberof AuditEvent + */ + schema?: SchemaRef; + /** + * The entity that performed the action. + * @type {Actor} + * @memberof AuditEvent + */ + actor: Actor; + /** + * The customer-defined identifier for the action, e.g. `assignment.submit`. + * @type {string} + * @memberof AuditEvent + */ + action: string; + /** + * Read-only. The CRUD classification of the action, derived from the matching Schema. + * @type {CRUDXType} + * @memberof AuditEvent + */ + action_type?: CRUDXType; + /** + * The entities affected by the action. + * @type {Array} + * @memberof AuditEvent + */ + targets: Target[]; + /** + * The scope (integration or institution) the Event belongs to. + * @type {Scope} + * @memberof AuditEvent + */ + scope: Scope; + /** + * Information about the underlying network request. Strongly suggested. + * @type {Context} + * @memberof AuditEvent + */ + context?: Context; + /** + * Arbitrary data relevant to the Event. Validated only if a Schema defines a data shape. + * @type {any} + * @memberof AuditEvent + */ + data?: any; } From d4225db35d00ede5db8f379004b86ba14f5293b1 Mon Sep 17 00:00:00 2001 From: Tenari Date: Tue, 30 Jun 2026 11:01:08 -0500 Subject: [PATCH 03/13] reset the api file changes from prettier --- src/types/api.ts | 418 ++++++++++++++++++++++------------------------- 1 file changed, 194 insertions(+), 224 deletions(-) diff --git a/src/types/api.ts b/src/types/api.ts index a80a96b..575b0f0 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -1,258 +1,228 @@ -import { Edlink } from ".."; +import { Edlink } from '..'; // import { serialize } from '../common'; -import { deepDefaults } from "../utils"; -import { RequestOptions, TokenSet, TokenSetType } from "./common"; +import { deepDefaults } from '../utils'; +import { RequestOptions, TokenSet, TokenSetType } from './common'; export type RequestConfig = { - url: string; - method: string; - headers?: Record; - data?: any; -}; + url: string; + method: string; + headers?: Record; + data?: any; +} class EdlinkError extends Error { - status?: number; - code?: string; - errors: any[]; - - constructor( - { message, status, code, errors = [] }: { - message: string; - status: number; - code?: string; - errors?: any[]; - }, - ) { - super(message); - this.status = status; - this.code = code; - this.errors = errors; - } + status?: number; + code?: string; + errors: any[]; + + constructor({ message, status, code, errors = [] }: { message: string; status: number; code?: string; errors?: any[] }) { + super(message); + this.status = status; + this.code = code; + this.errors = errors; + } } export class BearerTokenAPI { - private token_set: TokenSet; - private version: number; - public api: "graph" | "my" | "audit"; - public edlink: Edlink; - - constructor(edlink: Edlink, token_set: TokenSet) { - // Assign config - this.token_set = token_set; - this.version = edlink.version; - this.api = "graph"; // default for TokenSetType.Integration - if (token_set.type === TokenSetType.Application) { - this.api = "audit"; - } else if (token_set.type === TokenSetType.Person) { - this.api = "my"; + private token_set: TokenSet; + private version: number; + private api: 'graph' | 'my'; + public edlink: Edlink; + + constructor(edlink: Edlink, token_set: TokenSet) { + // Assign config + this.token_set = token_set; + this.version = edlink.version; + this.api = token_set.type === TokenSetType.Integration ? 'graph' : 'my'; + this.edlink = edlink; } - this.edlink = edlink; - } - async request( - config: string | RequestConfig, - options: RequestOptions = {}, - raw = false, - ): Promise { - const defaults: Record = { - method: "GET", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - }; - let req: Request; - let url: string; - // Format options into request params - const formatted_options = this.formatParams(options); - // If config is a string, set the url - if (typeof config === "string") { - url = config; - } else { - url = config.url; - } + async request( + config: string | RequestConfig, + options: RequestOptions = {}, + raw = false + ): Promise { + const defaults: Record = { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + } + }; + let req: Request; + let url: string; + // Format options into request params + const formatted_options = this.formatParams(options); + // If config is a string, set the url + if (typeof config === 'string') { + url = config; + } else { + url = config.url; + } - // Set url - const formattedUrl = new URL( - url.startsWith("http") + // Set url + const formattedUrl = new URL(url.startsWith('http') ? url - : `https://ed.link/api/v${this.version}/${this.api}${url}`, - ); - - // Add formatted options to the URL - for (const [key, value] of Object.entries(formatted_options)) { - formattedUrl.searchParams.append(key, value); - } - - // Set url - defaults.url = formattedUrl.toString(); - // Set access token - defaults.headers["Authorization"] = `Bearer ${this.token_set.access_token}`; - - if (typeof config === "string") { - req = new Request(formattedUrl, defaults); - } else { - // Set missing defaults - if (config.data !== undefined && config.data !== null) { - defaults.body = JSON.stringify(config.data); - defaults.headers["Content-Type"] = "application/json"; - } - // Merge defaults - deepDefaults(config, defaults); - // Set url - config.url = formattedUrl.toString(); - // Create request - req = new Request(config.url, config); - } - - // Check if the token needs to be refreshed if this is a person token set. - if ( - this.token_set.type === TokenSetType.Person && - this.token_set.refresh_token && this.requiresTokenRefresh() - ) { - // do the token refresh - const { access_token, refresh_token } = await this.edlink.auth.refresh( - this.token_set.refresh_token!, - ) - .catch(() => { - throw new EdlinkError({ - message: "Failed to refresh token.", - status: 400, - code: "BAD_REQUEST", - }); - }); - - // update the tokenset - this.token_set.access_token = access_token; - this.token_set.refresh_token = refresh_token; - this.token_set.expiration_date = new Date(Date.now() + 3600 * 1000); - } - // Make request - const response = await fetch(req); - const data = await response.json(); + : `https://ed.link/api/v${this.version}/${this.api}${url}`); - if (data.$warnings) { - if (this.edlink.log_level === "debug") { - for (const warning of data.$warnings) { - console.warn( - `Edlink API Warning: ${warning.code} ${warning.message}`, - ); + // Add formatted options to the URL + for (const [key, value] of Object.entries(formatted_options)) { + formattedUrl.searchParams.append(key, value); } - } - } - - if (data.$errors) { - if (this.edlink.log_level === "debug") { - for (const error of data.$errors) { - console.warn(`Edlink API Error: ${error.code} ${error.message}`); - } - } - throw new EdlinkError({ - ...data.$errors[0], - status: response.status, - errors: data.$errors.slice(1), - }); - } - return raw ? data : data.$data; - } + // Set url + defaults.url = formattedUrl.toString(); + // Set access token + defaults.headers['Authorization'] = `Bearer ${this.token_set.access_token}`; - async *paginate( - config: string | RequestConfig, - options: Record = {}, - until?: (item: T) => boolean, - formatter?: (raw: any) => T, - ): AsyncGenerator { - let next = null; - let data: T[] = []; - let remaining = options.limit; - - do { - if (next) { - if (typeof config === "string") { - config = next; + if (typeof config === 'string') { + req = new Request(formattedUrl, defaults); } else { - config.url = next; + // Set missing defaults + if (config.data !== undefined && config.data !== null) { + defaults.body = JSON.stringify(config.data); + defaults.headers['Content-Type'] = 'application/json'; + } + // Merge defaults + deepDefaults(config, defaults); + // Set url + config.url = formattedUrl.toString(); + // Create request + req = new Request(config.url, config); } - } - const response: { $data: T[]; $next?: string } = await this.request( - config, - options, - true, - ); - next = response.$next; - data = response.$data; - for (const item of data) { - const formatted = formatter ? formatter(item) : item; - if ( - (until && until(formatted)) || - (remaining !== undefined && remaining <= 0) - ) { - return; + // Check if the token needs to be refreshed if this is a person token set. + if (this.token_set.type === TokenSetType.Person && this.token_set.refresh_token && this.requiresTokenRefresh()) { + // do the token refresh + const { access_token, refresh_token } = await this.edlink.auth.refresh(this.token_set.refresh_token!) + .catch(() => { + throw new EdlinkError({ + message: 'Failed to refresh token.', + status: 400, + code: 'BAD_REQUEST' + }); + }); + + // update the tokenset + this.token_set.access_token = access_token; + this.token_set.refresh_token = refresh_token; + this.token_set.expiration_date = new Date(Date.now() + 3600 * 1000); } - yield formatted; - if (remaining) { - remaining--; + // Make request + const response = await fetch(req); + const data = await response.json(); + + if (data.$warnings) { + if (this.edlink.log_level === 'debug') { + for (const warning of data.$warnings) { + console.warn(`Edlink API Warning: ${warning.code} ${warning.message}`); + } + } } - } - } while (next && (remaining === undefined || remaining > 0)); - } - private requiresTokenRefresh(tokens: TokenSet = this.token_set): boolean { - if (tokens.type === TokenSetType.Integration) { - return false; + if (data.$errors) { + if (this.edlink.log_level === 'debug') { + for (const error of data.$errors) { + console.warn(`Edlink API Error: ${error.code} ${error.message}`); + } + } + throw new EdlinkError({ + ...data.$errors[0], + status: response.status, + errors: data.$errors.slice(1) + }); + } + + return raw ? data : data.$data; } - if (tokens.expiration_date == null || tokens.access_token == null) { - return true; + async *paginate( + config: string | RequestConfig, + options: Record = {}, + until?: (item: T) => boolean, + formatter?: (raw: any) => T + ): AsyncGenerator { + let next = null; + let data: T[] = []; + let remaining = options.limit; + + do { + if (next) { + if (typeof config === 'string') { + config = next; + } else { + config.url = next; + } + } + + const response: { $data: T[]; $next?: string } = await this.request( + config, + options, + true + ); + next = response.$next; + data = response.$data; + for (const item of data) { + const formatted = formatter ? formatter(item) : item; + if ((until && until(formatted)) || (remaining !== undefined && remaining <= 0)) { + return; + } + yield formatted; + if (remaining) { + remaining--; + } + } + } while (next && (remaining === undefined || remaining > 0)); } - const current_date = new Date(); - const expiration_date = new Date(tokens.expiration_date); - - // Set the expiration date to 5 minutes earlier for safety. - expiration_date.setMinutes(expiration_date.getMinutes() - 5); + private requiresTokenRefresh(tokens: TokenSet = this.token_set): boolean { + if (tokens.type === TokenSetType.Integration) { + return false; + } - return current_date > expiration_date; - } + if (tokens.expiration_date == null || tokens.access_token == null) { + return true; + } - public formatParams(params: RequestOptions): Record { - const formatted: { - $idempotency?: string; - $filter?: string; - $expand?: string; - $properties?: string; - } & Record = {}; + const current_date = new Date(); + const expiration_date = new Date(tokens.expiration_date); - const mappings: Record< - string, - string | { prop: string; mod: (value: any) => any } - > = { - idempotency: "$idempotency", - filter: { prop: "$filter", mod: JSON.stringify }, - properties: { - prop: "$properties", - mod: (value: string[]) => value.join(","), - }, - expand: { prop: "$expand", mod: (value: string[]) => value.join(",") }, - }; + // Set the expiration date to 5 minutes earlier for safety. + expiration_date.setMinutes(expiration_date.getMinutes() - 5); - for (const [key, value] of Object.entries(params)) { - const mapping = mappings[key]; + return current_date > expiration_date; + } + + public formatParams(params: RequestOptions): Record { + const formatted: { + $idempotency?: string; + $filter?: string; + $expand?: string; + $properties?: string; + } & Record = {}; + + const mappings: Record any }> = { + idempotency: '$idempotency', + filter: { prop: '$filter', mod: JSON.stringify }, + properties: { prop: '$properties', mod: (value: string[]) => value.join(',') }, + expand: { prop: '$expand', mod: (value: string[]) => value.join(',') }, + } - if (mapping) { - if (typeof mapping === "string") { - formatted[mapping] = value; - } else { - const { prop, mod } = mapping; - formatted[prop] = mod(value); + for (const [key, value] of Object.entries(params)) { + const mapping = mappings[key]; + + if (mapping) { + if (typeof mapping === 'string') { + formatted[mapping] = value; + } else { + const { prop, mod } = mapping; + formatted[prop] = mod(value); + } + } else { + formatted[key] = value; + } } - } else { - formatted[key] = value; - } - } - return formatted; - } + return formatted; + } } From ef546bfc06cf21de150ba418891699b1d62974c4 Mon Sep 17 00:00:00 2001 From: Tenari Date: Tue, 30 Jun 2026 11:04:13 -0500 Subject: [PATCH 04/13] Update api.ts --- src/types/api.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/types/api.ts b/src/types/api.ts index 575b0f0..780e30a 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -26,14 +26,19 @@ class EdlinkError extends Error { export class BearerTokenAPI { private token_set: TokenSet; private version: number; - private api: 'graph' | 'my'; + public api: 'graph' | 'my' | 'audit'; public edlink: Edlink; constructor(edlink: Edlink, token_set: TokenSet) { // Assign config this.token_set = token_set; this.version = edlink.version; - this.api = token_set.type === TokenSetType.Integration ? 'graph' : 'my'; + this.api = 'graph';// default for TokenSetType.Integration + if (token_set.type === TokenSetType.Application) { + this.api = 'audit'; + } else if (token_set.type === TokenSetType.Person) { + this.api = 'my'; + } this.edlink = edlink; } From 022be90d31d26a50b74cfe698d79cf79a4619991 Mon Sep 17 00:00:00 2001 From: Tenari Date: Tue, 30 Jun 2026 11:06:26 -0500 Subject: [PATCH 05/13] reset common.ts to undo the pile of prettier changes --- src/types/common.ts | 380 +++++++++++++++++++++----------------------- 1 file changed, 185 insertions(+), 195 deletions(-) diff --git a/src/types/common.ts b/src/types/common.ts index 7a640c1..878e4cd 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -3,76 +3,78 @@ * @enum {string} */ export enum Subject { - // CEDS Subjects - LanguageArts = "CEDS.01", // EnglishLanguageAndLiterature in CEDS - Mathematics = "CEDS.02", - LifeAndPhysicalSciences = "CEDS.03", - SocialSciencesAndHistory = "CEDS.04", - VisualAndPerformingArts = "CEDS.05", - ReligiousEducationAndTheology = "CEDS.07", - PhysicalHealthAndSafetyEducation = "CEDS.08", - MilitaryScience = "CEDS.09", - InformationTechnology = "CEDS.10", - CommunicationAndAudioVisualTechnology = "CEDS.11", - BusinessAndMarketing = "CEDS.12", - Manufacturing = "CEDS.13", - HealthCareSciences = "CEDS.14", - PublicProtectiveAndGovernmentService = "CEDS.15", - HospitalityAndTourism = "CEDS.16", - ArchitectureAndConstruction = "CEDS.17", - AgricultureFoodAndNaturalResources = "CEDS.18", - HumanServices = "CEDS.19", - TransportationDistributionAndLogistics = "CEDS.20", - EngineeringAndTechnology = "CEDS.21", - Miscellaneous = "CEDS.22", - NonSubjectSpecific = "CEDS.23", - WorldLanguages = "CEDS.24", + // CEDS Subjects + LanguageArts = 'CEDS.01', // EnglishLanguageAndLiterature in CEDS + Mathematics = 'CEDS.02', + LifeAndPhysicalSciences = 'CEDS.03', + SocialSciencesAndHistory = 'CEDS.04', + VisualAndPerformingArts = 'CEDS.05', + ReligiousEducationAndTheology = 'CEDS.07', + PhysicalHealthAndSafetyEducation = 'CEDS.08', + MilitaryScience = 'CEDS.09', + InformationTechnology = 'CEDS.10', + CommunicationAndAudioVisualTechnology = 'CEDS.11', + BusinessAndMarketing = 'CEDS.12', + Manufacturing = 'CEDS.13', + HealthCareSciences = 'CEDS.14', + PublicProtectiveAndGovernmentService = 'CEDS.15', + HospitalityAndTourism = 'CEDS.16', + ArchitectureAndConstruction = 'CEDS.17', + AgricultureFoodAndNaturalResources = 'CEDS.18', + HumanServices = 'CEDS.19', + TransportationDistributionAndLogistics = 'CEDS.20', + EngineeringAndTechnology = 'CEDS.21', + Miscellaneous = 'CEDS.22', + NonSubjectSpecific = 'CEDS.23', + WorldLanguages = 'CEDS.24', - // Edlink Subjects - SpecialEducation = "EL.01", - ProfessionalDevelopment = "EL.02", + // Edlink Subjects + SpecialEducation = 'EL.01', + ProfessionalDevelopment = 'EL.02' } /** * @export * @enum {string} */ export enum GradeLevel { - Birth = "Birth", - Prenatal = "Prenatal", - InfantToddler = "IT", - Preschool = "PR", - Prekindergarten = "PK", - TransitionalKindergarten = "TK", - Kindergarten = "KG", - FirstGrade = "01", - SecondGrade = "02", - ThirdGrade = "03", - FourthGrade = "04", - FifthGrade = "05", - SixthGrade = "06", - SeventhGrade = "07", - EighthGrade = "08", - NinthGrade = "09", - TenthGrade = "10", - EleventhGrade = "11", - TwelfthGrade = "12", - GradeThirteen = "13", - Postsecondary = "PS", - Ungraded = "UG", - Other = "Other", + Birth = 'Birth', + Prenatal = 'Prenatal', + InfantToddler = 'IT', + Preschool = 'PR', + Prekindergarten = 'PK', + TransitionalKindergarten = 'TK', + Kindergarten = 'KG', + FirstGrade = '01', + SecondGrade = '02', + ThirdGrade = '03', + FourthGrade = '04', + FifthGrade = '05', + SixthGrade = '06', + SeventhGrade = '07', + EighthGrade = '08', + NinthGrade = '09', + TenthGrade = '10', + EleventhGrade = '11', + TwelfthGrade = '12', + GradeThirteen = '13', + Postsecondary = 'PS', + Ungraded = 'UG', + Other = 'Other' } export interface Identifier { - /** - * @type {string} - * @memberof Identifier - */ - type: string; - /** - * @type {string} - * @memberof Identifier - */ - value: string; + /** + * + * @type {string} + * @memberof Identifier + */ + type: string; + /** + * + * @type {string} + * @memberof Identifier + */ + value: string; } // @@ -83,36 +85,36 @@ export interface Identifier { * @enum {string} */ export enum IdentifierType { - SIS = "sis_id", - State = "state_id", - School = "school_id", - Username = "username", - Email = "email", - LDAP = "ldap", - LTI = "lti", + SIS = 'sis_id', + State = 'state_id', + School = 'school_id', + Username = 'username', + Email = 'email', + LDAP = 'ldap', + LTI = 'lti', - Edlink = "edlink_id", + Edlink = 'edlink_id', - NCES = "nces_id", - MDR = "mdr_id", - IPEDS = "ipeds_id", + NCES = 'nces_id', + MDR = 'mdr_id', + IPEDS = 'ipeds_id', - Aeries = "aeries_id", - Blackbaud = "blackbaud_id", - Blackboard = "blackboard_id", - Brightspace = "brightspace_id", - Bromcom = "bromcom_id", - Canvas = "canvas_id", - CanvasLti = "canvas_lti_id", - Clever = "clever_id", - Google = "google_id", - Illuminate = "illuminate_id", - Microsoft = "microsoft_id", - Moodle = "moodle_id", - OneRoster = "oneroster_id", - PowerSchool = "powerschool_id", - Schoology = "schoology_id", - Skyward = "skyward_id", + Aeries = 'aeries_id', + Blackbaud = 'blackbaud_id', + Blackboard = 'blackboard_id', + Brightspace = 'brightspace_id', + Bromcom = 'bromcom_id', + Canvas = 'canvas_id', + CanvasLti = 'canvas_lti_id', + Clever = 'clever_id', + Google = 'google_id', + Illuminate = 'illuminate_id', + Microsoft = 'microsoft_id', + Moodle = 'moodle_id', + OneRoster = 'oneroster_id', + PowerSchool = 'powerschool_id', + Schoology = 'schoology_id', + Skyward = 'skyward_id' } /** @@ -120,9 +122,9 @@ export enum IdentifierType { * @enum {string} */ export enum Gender { - Male = "male", - Female = "female", - Other = "other", + Male = 'male', + Female = 'female', + Other = 'other' } /** @@ -130,12 +132,11 @@ export enum Gender { * @enum {string} */ export enum Race { - AmericanIndianOrAlaskaNative = "american-indian-or-alaska-native", - Asian = "asian", - BlackOrAfricanAmerican = "black-or-african-american", - NativeHawaiianOrOtherPacificIslander = - "native-hawaiian-or-other-pacific-islander", - White = "white", + AmericanIndianOrAlaskaNative = 'american-indian-or-alaska-native', + Asian = 'asian', + BlackOrAfricanAmerican = 'black-or-african-american', + NativeHawaiianOrOtherPacificIslander = 'native-hawaiian-or-other-pacific-islander', + White = 'white' } /** @@ -143,144 +144,133 @@ export enum Race { * @enum {string} */ export enum Role { - Student = "student", - DistrictAdministrator = "district-administrator", - Administrator = "administrator", - Teacher = "teacher", - TeachingAssistant = "ta", - Staff = "staff", - Aide = "aide", - Observer = "observer", - Parent = "parent", - Guardian = "guardian", - Designer = "designer", - Member = "member", + Student = 'student', + DistrictAdministrator = 'district-administrator', + Administrator = 'administrator', + Teacher = 'teacher', + TeachingAssistant = 'ta', + Staff = 'staff', + Aide = 'aide', + Observer = 'observer', + Parent = 'parent', + Guardian = 'guardian', + Designer = 'designer', + Member = 'member' } export enum TokenSetType { - Integration = "integration", - Person = "person", - Application = "application", -} + Integration = 'integration', + Person = 'person', + Application = 'application' +}; export type BaseTokenSet = { - type: TokenSetType; - token_type?: "Bearer"; - access_token: string; - refresh_token?: string; - context?: Record; + type: TokenSetType; + token_type?: 'Bearer'; + access_token: string; + refresh_token?: string; + context?: Record; }; /** * Important note: If you do not provide a refresh token the SDK will not be able to refresh the token for you and requests made an hour after the initial token exchange will fail */ export type PersonTokenSet = BaseTokenSet & { - type: TokenSetType.Person; - access_token?: string; - refresh_token?: string; - expires_in?: Date; - expiration_date?: Date; + type: TokenSetType.Person; + access_token?: string; + refresh_token?: string; + expires_in?: Date; + expiration_date?: Date; }; export type IntegrationTokenSet = BaseTokenSet & { - type: TokenSetType.Integration; - access_token: string; -}; - -export type ApplicationTokenSet = BaseTokenSet & { - type: TokenSetType.Application; - access_token: string; + type: TokenSetType.Integration; + access_token: string; }; -export type TokenSet = - | PersonTokenSet - | IntegrationTokenSet - | ApplicationTokenSet; +export type TokenSet = PersonTokenSet | IntegrationTokenSet; export type RequestOptionsPaging = { - limit?: number; - filter?: Record; + limit?: number; + filter?: Record; } & RequestOptionsGet; export type RequestOptionsGet = { - expand?: string[]; + expand?: string[]; } & RequestOptionsBase; export type RequestOptionsPost = { - idempotency?: string; + idempotency?: string; } & RequestOptionsBase; export type RequestOptionsBase = { - properties?: string[]; + properties?: string[]; }; -export type RequestOptions = - | RequestOptionsPaging - | RequestOptionsGet - | RequestOptionsBase; +export type RequestOptions = RequestOptionsPaging | RequestOptionsGet | RequestOptionsBase; export enum ProductState { - Active = "active", - Inactive = "inactive", - Upcoming = "upcoming", - Development = "development", - Sunsetting = "sunsetting", - Deprecated = "deprecated", -} + Active = 'active', + Inactive = 'inactive', + Upcoming = 'upcoming', + Development = 'development', + Sunsetting = 'sunsetting', + Deprecated = 'deprecated' +}; export enum IntegrationState { - Requested = "requested", - Inactive = "inactive", - Active = "active", - Error = "error", - Disabled = "disabled", - Paused = "paused", - Pending = "pending", - Scheduled = "scheduled", -} + Requested = 'requested', + Inactive = 'inactive', + Active = 'active', + Error = 'error', + Disabled = 'disabled', + Paused = 'paused', + Pending = 'pending', + Scheduled = 'scheduled' +}; export type Integration = { - state: IntegrationState; - id: string; - created_date: string; - updated_date: string; - permissions: string[]; - scope: string; - start_date: string; - end_date: string; - locked: boolean; - application_id: string; - source_id: string; - destination_id: string; - region_id: string; + state: IntegrationState; + id: string; + created_date: string; + updated_date: string; + permissions: string[]; + scope: string; + start_date: string; + end_date: string; + locked: boolean; + application_id: string; + source_id: string; + destination_id: string; + region_id: string; }; export enum SourceState { - Inactive = "inactive", // Waiting to be validated (set when configuration changes) - Active = "active", // First sync was successful, scheduled to sync regularly - Error = "error", // Validation or sync failed - Disabled = "disabled", // Disabled manually - Destroyed = "destroyed", // Basically just waiting for cleanup. -} + Inactive = 'inactive', // Waiting to be validated (set when configuration changes) + Active = 'active', // First sync was successful, scheduled to sync regularly + Error = 'error', // Validation or sync failed + Disabled = 'disabled', // Disabled manually + Destroyed = 'destroyed' // Basically just waiting for cleanup. +}; export type Source = { - state: SourceState; - id: string; - created_date: string; - updated_date: string; - name: string; - sync_interval: number; - provider_id: string; - properties: Record; - team_id: string; - provider: { - active: boolean; - requires_administrator_login: boolean; - requires_administrator_consent: boolean; - requires_remote_configuration: boolean; - allows_data_sync: boolean; + state: SourceState; id: string; + created_date: string; + updated_date: string; name: string; - application: string; - }; -}; + sync_interval: number; + provider_id: string; + properties: Record; + team_id: string; + provider: { + active: boolean; + requires_administrator_login: boolean; + requires_administrator_consent: boolean; + requires_remote_configuration: boolean; + allows_data_sync: boolean; + id: string; + name: string; + application: string; + } +}; \ No newline at end of file From 36fc80358e751b4129f6100867e4d67cb9121397 Mon Sep 17 00:00:00 2001 From: Tenari Date: Tue, 30 Jun 2026 11:07:33 -0500 Subject: [PATCH 06/13] Update common.ts --- src/types/common.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/types/common.ts b/src/types/common.ts index 878e4cd..809dc22 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -188,7 +188,15 @@ export type IntegrationTokenSet = BaseTokenSet & { access_token: string; }; -export type TokenSet = PersonTokenSet | IntegrationTokenSet; +export type ApplicationTokenSet = BaseTokenSet & { + type: TokenSetType.Application; + access_token: string; +}; + +export type TokenSet = + | PersonTokenSet + | IntegrationTokenSet + | ApplicationTokenSet; export type RequestOptionsPaging = { limit?: number; @@ -273,4 +281,4 @@ export type Source = { name: string; application: string; } -}; \ No newline at end of file +}; From c9c3b29c95ffbfa06a4d2c5c2c2fe98a1b71c0ba Mon Sep 17 00:00:00 2001 From: Tenari Date: Tue, 30 Jun 2026 11:10:21 -0500 Subject: [PATCH 07/13] undo --- src/types/index.ts | 42 ++++++++++++++++++++---------------------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/src/types/index.ts b/src/types/index.ts index 1b1fa13..2942cfe 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,22 +1,20 @@ -export * from "./api"; -export * from "./audit_schema"; -export * from "./audit_event"; -export * from "./common"; -export * from "./address"; -export * from "./agent"; -export * from "./course"; -export * from "./class"; -export * from "./demographics"; -export * from "./district"; -export * from "./enrollment"; -export * from "./event"; -export * from "./person"; -export * from "./school"; -export * from "./section"; -export * from "./session"; -export * from "./assignment"; -export * from "./category"; -export * from "./submission"; -export * from "./attachment"; -export * from "./license"; -export * from "./product"; +export * from './api'; +export * from './common'; +export * from './address'; +export * from './agent'; +export * from './course'; +export * from './class'; +export * from './demographics'; +export * from './district'; +export * from './enrollment'; +export * from './event'; +export * from './person'; +export * from './school'; +export * from './section'; +export * from './session'; +export * from './assignment'; +export * from './category'; +export * from './submission'; +export * from './attachment'; +export * from './license'; +export * from './product'; \ No newline at end of file From 6d281b427ff0c8e4a7d76760281f03be5ec7c8ac Mon Sep 17 00:00:00 2001 From: Tenari Date: Tue, 30 Jun 2026 11:11:57 -0500 Subject: [PATCH 08/13] Update index.ts --- src/types/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/types/index.ts b/src/types/index.ts index 2942cfe..48417de 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,6 @@ export * from './api'; +export * from './audit_schema'; +export * from './audit_event'; export * from './common'; export * from './address'; export * from './agent'; @@ -17,4 +19,4 @@ export * from './category'; export * from './submission'; export * from './attachment'; export * from './license'; -export * from './product'; \ No newline at end of file +export * from './product'; From 7af1f61bf4730610e94b964cdaf974c3afafe460 Mon Sep 17 00:00:00 2001 From: Tenari Date: Tue, 30 Jun 2026 16:32:26 -0500 Subject: [PATCH 09/13] fix type error --- src/types/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types/api.ts b/src/types/api.ts index 780e30a..098585b 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -181,7 +181,7 @@ export class BearerTokenAPI { } private requiresTokenRefresh(tokens: TokenSet = this.token_set): boolean { - if (tokens.type === TokenSetType.Integration) { + if (tokens.type === TokenSetType.Integration || tokens.type === TokenSetType.Application) { return false; } From 9980a59794c8c48b100228b5e6b2a42992ad70a5 Mon Sep 17 00:00:00 2001 From: Tenari Date: Tue, 30 Jun 2026 16:40:25 -0500 Subject: [PATCH 10/13] testing stuff --- test/main.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/main.test.ts b/test/main.test.ts index a96ddcc..36b1171 100644 --- a/test/main.test.ts +++ b/test/main.test.ts @@ -30,8 +30,6 @@ const edlink = new Edlink({ log_level: 'silent' }); -// jest.setTimeout(10000); -`` describe('User', () => { it('auth', async () => { // const grant = await edlink.auth.grant({ @@ -443,6 +441,7 @@ describe('Request Options', () => { }); }); +if (process.env.REFRESH_TOKEN) { describe('Categories', () => { it('should list categories for a class', async () => { const refresh = await edlink.auth.refresh(process.env.REFRESH_TOKEN!); @@ -506,4 +505,7 @@ describe('Categories', () => { expect(response).toBeDefined(); } }); -}); \ No newline at end of file +}); +} else { + console.error('REFRESH_TOKEN is not set, skipping tests that require it'); +} From 7e870c631b6d983cc4379e8a02b650352d16c366 Mon Sep 17 00:00:00 2001 From: Tenari Date: Tue, 30 Jun 2026 17:13:05 -0500 Subject: [PATCH 11/13] audit tests --- src/audit/index.ts | 15 ++++++++ src/common/audit_schema.ts | 1 - src/common/index.ts | 2 + src/index.ts | 13 ++++++- test/main.test.ts | 77 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 105 insertions(+), 3 deletions(-) create mode 100644 src/audit/index.ts diff --git a/src/audit/index.ts b/src/audit/index.ts new file mode 100644 index 0000000..47cd0a2 --- /dev/null +++ b/src/audit/index.ts @@ -0,0 +1,15 @@ +import { Edlink } from '..'; +import { AuditEvents, AuditSchemas } from '../common'; +import { BearerTokenAPI, TokenSet } from '../types'; + +export class Audit extends BearerTokenAPI { + public events: AuditEvents; + public schemas: AuditSchemas; + + constructor(edlink: Edlink, token_set: TokenSet) { + super(edlink, token_set); + + this.events = new AuditEvents(this); + this.schemas = new AuditSchemas(this); + } +} diff --git a/src/common/audit_schema.ts b/src/common/audit_schema.ts index ec83871..ede164c 100644 --- a/src/common/audit_schema.ts +++ b/src/common/audit_schema.ts @@ -5,7 +5,6 @@ import { CRUDXType, RequestOptionsBase, RequestOptionsGet, - RequestOptionsPaging, RequestOptionsPost, } from "../types"; diff --git a/src/common/index.ts b/src/common/index.ts index 4131657..7814aa1 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -1,3 +1,5 @@ +export { AuditEvents } from './audit_event'; +export { AuditSchemas } from './audit_schema'; export { Agents } from './agents'; export { Classes } from './classes'; export { Courses } from './courses'; diff --git a/src/index.ts b/src/index.ts index c738990..f9cd804 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import { serialize } from './common'; +import { Audit } from './audit'; import { Graph } from './graph'; -import { IntegrationTokenSet, PersonTokenSet, TokenSetType } from './types'; +import { ApplicationTokenSet, IntegrationTokenSet, PersonTokenSet, TokenSetType } from './types'; import { User } from './user'; import { Auth } from './user/auth'; @@ -44,10 +45,18 @@ export class Edlink { * @returns {User} An instance of the Edlink User API interface using the provided TokenSet */ public use(token_set: PersonTokenSet): User; + /** + * Initialize an instance of the Edlink Audit API with an application token set. + * @param token_set The ApplicationTokenSet used to authenticate the request + * @returns {Audit} An instance of the Edlink Audit API interface using the provided TokenSet + */ + public use(token_set: ApplicationTokenSet): Audit; - public use(token_set: PersonTokenSet | IntegrationTokenSet): User | Graph { + public use(token_set: PersonTokenSet | IntegrationTokenSet | ApplicationTokenSet): User | Graph | Audit { if (token_set.type === TokenSetType.Person) { return new User(this, token_set); + } else if (token_set.type === TokenSetType.Application) { + return new Audit(this, token_set); } else { return new Graph(this, token_set); } diff --git a/test/main.test.ts b/test/main.test.ts index 36b1171..e14a75d 100644 --- a/test/main.test.ts +++ b/test/main.test.ts @@ -11,6 +11,8 @@ import { Person, TokenSetType, IntegrationTokenSet, + ApplicationTokenSet, + ScopeType, Agent, PersonTokenSet, } from '../src'; @@ -441,6 +443,81 @@ describe('Request Options', () => { }); }); +if (process.env.APPLICATION_SECRET_KEY) { + const application_token_set: ApplicationTokenSet = { + access_token: process.env.APPLICATION_SECRET_KEY!, + type: TokenSetType.Application + }; + + describe('Audit', () => { + let created_event_id: string; + + it('POST /api/v2/audit/events', async () => { + const result = await edlink.use(application_token_set).events.create({ + actor: { + type: 'person', + identifiers: [{ value: 'test_user_1', issuer: 'test' }] + }, + action: 'user.login', + targets: [ + { type: 'session', identifiers: [{ value: 'session_abc', issuer: 'test' }] } + ], + scope: { + id: process.env.AUDIT_SCOPE_ID! as `${string}-${string}-${string}-${string}-${string}`, + type: ScopeType.Integration + }, + context: { + http_method: 'POST', + path: '/login', + ip: '1.2.3.4', + user_agent: 'Jest' + }, + data: { + internal_user_id: 'test_user_1', + application_name: 'Test Suite' + } + }); + expect(result).toBeDefined(); + expect(result.id).toBeDefined(); + created_event_id = result.id!; + }); + + it('GET /api/v2/audit/events/:event_id', async () => { + const event = await edlink.use(application_token_set).events.fetch(created_event_id); + expect(event).toBeDefined(); + expect(event.id).toBe(created_event_id); + expect(event.action).toBe('user.login'); + expect(event.actor).toBeDefined(); + expect(event.actor.type).toBe('person'); + expect(event.targets).toBeDefined(); + expect(Array.isArray(event.targets)).toBe(true); + expect(event.scope).toBeDefined(); + expect(event.created_date).toBeDefined(); + }); + + it('GET /api/v2/audit/events/:scope_type/:scope_id', async () => { + let found = false; + for await (const event of edlink.use(application_token_set).events.list('integration', process.env.AUDIT_SCOPE_ID!, { limit: 25 })) { + expect(event).toBeDefined(); + expect(event.id).toBeDefined(); + expect(event.action).toBeDefined(); + if (event.id === created_event_id) { + found = true; + } + } + expect(found).toBe(true); + }); + + it('GET /api/v2/audit/events/:scope_type/:scope_id with action filter', async () => { + for await (const event of edlink.use(application_token_set).events.list('integration', process.env.AUDIT_SCOPE_ID!, { limit: 5, filter: { action: 'user.login' } })) { + expect(event.action).toBe('user.login'); + } + }); + }); +} else { + console.error('APPLICATION_SECRET_KEY is not set, skipping Audit tests'); +} + if (process.env.REFRESH_TOKEN) { describe('Categories', () => { it('should list categories for a class', async () => { From 6407d2cebb800d5884429ced2392d51f55d8f322 Mon Sep 17 00:00:00 2001 From: Tenari Date: Wed, 1 Jul 2026 13:29:43 -0500 Subject: [PATCH 12/13] use proper filter mechanism --- src/types/api.ts | 4 +++- test/main.test.ts | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/types/api.ts b/src/types/api.ts index 098585b..bf9d470 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -3,6 +3,8 @@ import { Edlink } from '..'; import { deepDefaults } from '../utils'; import { RequestOptions, TokenSet, TokenSetType } from './common'; +const EDLINK_URL = process.env.ALTERNATE_EDLINK_URL ?? 'https://ed.link'; + export type RequestConfig = { url: string; method: string; @@ -68,7 +70,7 @@ export class BearerTokenAPI { // Set url const formattedUrl = new URL(url.startsWith('http') ? url - : `https://ed.link/api/v${this.version}/${this.api}${url}`); + : `${EDLINK_URL}/api/v${this.version}/${this.api}${url}`); // Add formatted options to the URL for (const [key, value] of Object.entries(formatted_options)) { diff --git a/test/main.test.ts b/test/main.test.ts index e14a75d..e800d28 100644 --- a/test/main.test.ts +++ b/test/main.test.ts @@ -480,6 +480,7 @@ if (process.env.APPLICATION_SECRET_KEY) { expect(result).toBeDefined(); expect(result.id).toBeDefined(); created_event_id = result.id!; + await new Promise((r) => setTimeout(r, 3000)); // must wait for async_inserts to clickhouse to work }); it('GET /api/v2/audit/events/:event_id', async () => { @@ -509,7 +510,7 @@ if (process.env.APPLICATION_SECRET_KEY) { }); it('GET /api/v2/audit/events/:scope_type/:scope_id with action filter', async () => { - for await (const event of edlink.use(application_token_set).events.list('integration', process.env.AUDIT_SCOPE_ID!, { limit: 5, filter: { action: 'user.login' } })) { + for await (const event of edlink.use(application_token_set).events.list('integration', process.env.AUDIT_SCOPE_ID!, { limit: 5, filter: { action: [{operator: 'equals', value: 'user.login' }]} })) { expect(event.action).toBe('user.login'); } }); From 48a0c48f8aee6aaa4160f83894922e39708742e3 Mon Sep 17 00:00:00 2001 From: Tenari Date: Wed, 1 Jul 2026 13:51:04 -0500 Subject: [PATCH 13/13] add tests for the schemas methods --- test/main.test.ts | 95 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/test/main.test.ts b/test/main.test.ts index e800d28..48c33ff 100644 --- a/test/main.test.ts +++ b/test/main.test.ts @@ -13,6 +13,7 @@ import { IntegrationTokenSet, ApplicationTokenSet, ScopeType, + CRUDXType, Agent, PersonTokenSet, } from '../src'; @@ -451,6 +452,9 @@ if (process.env.APPLICATION_SECRET_KEY) { describe('Audit', () => { let created_event_id: string; + let created_schema_id: string; + let created_schema_action: string; + let created_schema_event_id: string; it('POST /api/v2/audit/events', async () => { const result = await edlink.use(application_token_set).events.create({ @@ -509,6 +513,97 @@ if (process.env.APPLICATION_SECRET_KEY) { expect(found).toBe(true); }); + it('POST /api/v2/audit/schemas', async () => { + created_schema_action = `test.schema.${Date.now()}`; + const schema = await edlink.use(application_token_set).schemas.create( + { id: created_schema_action, type: CRUDXType.Create }, + { + type: 'object', + properties: { + test_field: { type: 'string', description: 'A test field' } + }, + required: ['test_field'] + }, + 'lax' + ); + expect(schema).toBeDefined(); + expect(schema.id).toBeDefined(); + expect(schema.action.id).toBe(created_schema_action); + expect(schema.action.type).toBe(CRUDXType.Create); + expect(schema.validation_level).toBe('lax'); + created_schema_id = schema.id; + }); + + it('GET /api/v2/audit/schemas/:schema_id_or_action (by id)', async () => { + const schema = await edlink.use(application_token_set).schemas.fetch(created_schema_id); + expect(schema).toBeDefined(); + expect(schema.id).toBe(created_schema_id); + expect(schema.action.id).toBe(created_schema_action); + expect(schema.action.type).toBe(CRUDXType.Create); + expect(schema.validation_level).toBe('lax'); + }); + + it('GET /api/v2/audit/schemas/:schema_id_or_action (by action)', async () => { + const schema = await edlink.use(application_token_set).schemas.fetch(created_schema_action); + expect(schema).toBeDefined(); + expect(schema.id).toBe(created_schema_id); + expect(schema.action.id).toBe(created_schema_action); + }); + + it('PATCH /api/v2/audit/schemas', async () => { + const schema = await edlink.use(application_token_set).schemas.update( + created_schema_action, + { + action_type: CRUDXType.Other, + validation_level: 'strict', + data: { + type: 'object', + properties: { + test_field: { type: 'string', description: 'A test field' }, + extra_field: { type: 'number', description: 'An extra field' } + }, + required: ['test_field'] + } + } + ); + expect(schema).toBeDefined(); + expect(schema.id).toBe(created_schema_id); + expect(schema.action.id).toBe(created_schema_action); + expect(schema.action.type).toBe(CRUDXType.Other); + expect(schema.validation_level).toBe('strict'); + }); + + it('POST /api/v2/audit/events (with schema)', async () => { + const result = await edlink.use(application_token_set).events.create({ + actor: { + type: 'system', + identifiers: [{ value: 'test_system_1', issuer: 'test' }] + }, + action: created_schema_action, + targets: [ + { type: 'resource', identifiers: [{ value: 'resource_xyz', issuer: 'test' }] } + ], + scope: { + id: process.env.AUDIT_SCOPE_ID! as `${string}-${string}-${string}-${string}-${string}`, + type: ScopeType.Integration + }, + data: { test_field: 'hello from schema test' } + }); + expect(result).toBeDefined(); + expect(result.id).toBeDefined(); + created_schema_event_id = result.id!; + await new Promise((r) => setTimeout(r, 3000)); + }); + + it('GET /api/v2/audit/events/:event_id (schema id present)', async () => { + const event = await edlink.use(application_token_set).events.fetch(created_schema_event_id); + expect(event).toBeDefined(); + expect(event.id).toBe(created_schema_event_id); + expect(event.action).toBe(created_schema_action); + expect(event.schema).toBeDefined(); + expect(event.schema?.id).toBe(created_schema_id); + }); + it('GET /api/v2/audit/events/:scope_type/:scope_id with action filter', async () => { for await (const event of edlink.use(application_token_set).events.list('integration', process.env.AUDIT_SCOPE_ID!, { limit: 5, filter: { action: [{operator: 'equals', value: 'user.login' }]} })) { expect(event.action).toBe('user.login');