From 7ca30607c1400d06cdc12c70fbdb9ea5a50a8ee7 Mon Sep 17 00:00:00 2001 From: safenestdev Date: Sun, 8 Nov 2026 08:35:49 +0100 Subject: [PATCH 1/2] fix(account): match account and platform types to the API; add status, DSA report, fleet and key quota methods - UsageSummary now has the daily usage and quota objects the API returns; getUsageSummary accepts a date. Old fields are kept, deprecated. - UsageMonthlyResult carries the verification allowance block. - Pricing, consent and audit log records use the API's field names (isPopular, ctaLink, userId); audit log details is a string. - Incident status, severity and category values match the API; rows carry policy action, recommended status and actions, capped-action fields and test-traffic marking. Review inputs take typed risk levels and categories, and batch review requires moderator_external_id like single review. - Audit receipts accept resolve/reopen reviews, harm_descriptor and external_ref. - New methods: getDsaTransparencyReport (JSON or CSV text), getStatus, ping, pingComponent (a 503 is returned, not thrown or retried), getFleet, getFleetDevice, getApiKeyQuota. Closes #60 --- README.md | 67 ++++- src/client.ts | 215 ++++++++++++++- src/constants.ts | 13 + src/index.ts | 45 ++++ src/types/account.ts | 46 +++- src/types/audit.ts | 23 +- src/types/compliance.ts | 99 +++++++ src/types/fleet.ts | 45 ++++ src/types/incidents.ts | 74 +++++- src/types/index.ts | 91 +++++-- src/types/pricing.ts | 8 +- src/types/status.ts | 65 +++++ tests/account.test.ts | 532 +++++++++++++++++++++++++++++++++++++ tests/newEndpoints.test.ts | 5 +- 14 files changed, 1263 insertions(+), 65 deletions(-) create mode 100644 src/types/compliance.ts create mode 100644 src/types/fleet.ts create mode 100644 src/types/status.ts create mode 100644 tests/account.test.ts diff --git a/README.md b/README.md index 5eaa964..3481987 100644 --- a/README.md +++ b/README.md @@ -1336,12 +1336,21 @@ const result = await tuteliq.rectifyData({ Get audit trail of all data operations. ```typescript -const logs = await tuteliq.getAuditLogs({ limit: 50 }) -logs.entries.forEach(entry => { - console.log(entry.action, entry.timestamp, entry.details) +const { audit_logs } = await tuteliq.getAuditLogs({ limit: 50 }) +audit_logs.forEach(entry => { + console.log(entry.action, entry.created_at) }) ``` +#### `getApiKeyQuota()` + +Get active API keys, the tier maximum and whether another key can be created. + +```typescript +const quota = await tuteliq.getApiKeyQuota() +console.log(quota.active_keys, '/', quota.max_keys, quota.can_create) +``` + --- ### Breach Management @@ -1449,15 +1458,15 @@ The `credits_used` field is included in every response body. Credit balance is a ### Usage API Methods -#### `getUsageSummary()` +#### `getUsageSummary(date?)` -Get usage summary for the current billing period. +Get one day's request counts for this API key (defaults to today), with the tier's limits. For billing-period usage, use `getUsageMonthly()`. ```typescript -const summary = await tuteliq.getUsageSummary() -console.log('Used:', summary.messages_used) -console.log('Limit:', summary.message_limit) -console.log('Percent:', summary.usage_percentage) +const summary = await tuteliq.getUsageSummary('2026-11-07') +console.log('Requests:', summary.usage.totalRequests) +console.log('Errors:', summary.usage.errorRequests) +console.log('Remaining today:', summary.quota.remainingToday) // -1 = no daily limit ``` #### `getQuota()` @@ -1504,6 +1513,46 @@ if (monthly.recommendations?.should_upgrade) { } ``` +On an enterprise verification plan, `monthly.verification` carries the period's verification allowance, and `usage` counts verifications. + +### DSA Transparency Report + +#### `getDsaTransparencyReport({ from, to, format? })` + +Your moderation numbers for a reporting period (at most 366 days), laid out on the DSA transparency-report template. Aggregates only; test traffic is excluded. Included in current paid plans. + +```typescript +const report = await tuteliq.getDsaTransparencyReport({ from: '2026-01-01', to: '2026-06-30' }) +console.log(report.scope.incidents_counted, report.sections.length) + +// CSV text, one row per template indicator and category metric +const csv = await tuteliq.getDsaTransparencyReport({ from: '2026-01-01', to: '2026-06-30', format: 'csv' }) +``` + +### Agent Fleet + +#### `getFleet()` / `getFleetDevice(deviceId)` + +Latest device attestations for the org behind the API key, with a compliance summary. + +```typescript +const { devices, summary } = await tuteliq.getFleet() +console.log(`${summary.compliance_pct}% compliant`) +const { device } = await tuteliq.getFleetDevice(devices[0].device_id) +``` + +### Status + +#### `getStatus()` / `ping()` / `pingComponent(component)` + +Public health endpoints. A 503 from them is an answer, not an error: these methods return it typed and never retry. + +```typescript +const status = await tuteliq.getStatus() // status: 'operational' | 'degraded' | 'major_outage' +const pong = await tuteliq.ping() // { status: 'ok', timestamp } +const db = await tuteliq.pingComponent('database') // { healthy: false, ... } when failing +``` + --- ## Error Handling diff --git a/src/client.ts b/src/client.ts index e1451e1..fe072e5 100644 --- a/src/client.ts +++ b/src/client.ts @@ -52,6 +52,16 @@ import { BreachResult, UpdateBreachInput, GetBreachesOptions, + // API key quota + ApiKeyQuota, + // Status, fleet, DSA transparency report + ApiStatus, + PingResult, + ComponentPing, + FleetResult, + FleetDeviceResult, + DsaTransparencyReportInput, + DsaTransparencyReport, // Media types AnalyzeVoiceInput, VoiceAnalysisResult, @@ -448,6 +458,53 @@ export class Tuteliq { method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', path: string, body?: unknown + ): Promise { + return this.send(method, path, body, async (response) => { + await this.throwIfError(response); + return await response.json() as T; + }); + } + + /** + * Make an authenticated GET request whose body is text (e.g. a CSV export) + */ + private async requestText(path: string): Promise { + return this.send('GET', path, undefined, async (response) => { + await this.throwIfError(response); + return await response.text(); + }); + } + + /** + * GET a status endpoint. A 503 there is an answer (the service or + * component is down), so it is returned like a 200 instead of thrown. + */ + private async requestStatus(path: string): Promise { + return this.send('GET', path, undefined, async (response) => { + if (response.status !== 503) await this.throwIfError(response); + return await response.json() as T; + }); + } + + /** + * Throw the typed error for a non-2xx response + */ + private async throwIfError(response: Response): Promise { + if (!response.ok) { + const errorBody = await response.json().catch(() => ({})) as ApiError; + this.handleErrorResponse(response.status, errorBody, response.headers); + } + } + + /** + * Send a request, record response metadata and hand the response to + * `read`. Network failures and timeouts become typed errors. + */ + private async send( + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', + path: string, + body: unknown, + read: (response: Response) => Promise ): Promise { const url = `${API_BASE_URL}${path}`; const startTime = Date.now(); @@ -501,13 +558,7 @@ export class Tuteliq { // Usage warning header this._usageWarning = response.headers.get('x-usage-warning'); - // Handle error responses - if (!response.ok) { - const errorBody = await response.json().catch(() => ({})) as ApiError; - this.handleErrorResponse(response.status, errorBody, response.headers); - } - - return await response.json() as T; + return await read(response); } catch (error) { clearTimeout(timeoutId); this._lastLatencyMs = Date.now() - startTime; @@ -1508,20 +1559,26 @@ export class Tuteliq { // ========================================================================= /** - * Get usage summary for the current billing period + * Get one day's request counts for this API key, with the tier's limits. + * For billing-period usage, use `getUsageMonthly()`. + * + * @param date - Date in YYYY-MM-DD format (defaults to today) * * @example * ```typescript * const summary = await tuteliq.getUsageSummary() - * console.log('Used:', summary.messages_used) - * console.log('Limit:', summary.message_limit) - * console.log('Percent:', summary.usage_percentage) + * console.log('Requests today:', summary.usage.totalRequests) + * console.log('Errors:', summary.usage.errorRequests) + * console.log('Per minute:', summary.quota.requestsPerMinute) * ``` */ - async getUsageSummary(): Promise { + async getUsageSummary(date?: string): Promise { + const params = new URLSearchParams(); + if (date) params.set('date', date); + const query = params.toString() ? `?${params.toString()}` : ''; return this.requestWithRetry( 'GET', - '/api/v1/usage/summary' + `/api/v1/usage/summary${query}` ); } @@ -1693,6 +1750,23 @@ export class Tuteliq { ); } + /** + * Get the API key quota for your tier: active keys, the tier maximum and + * whether another key can be created. + * + * @example + * ```typescript + * const quota = await tuteliq.getApiKeyQuota() + * if (!quota.can_create) console.log('Upgrade:', quota.links.upgrade) + * ``` + */ + async getApiKeyQuota(): Promise { + return this.requestWithRetry( + 'GET', + '/api/v1/account/api-keys/quota' + ); + } + // ========================================================================= // Breach Management (GDPR Article 33/34) // ========================================================================= @@ -2927,6 +3001,9 @@ export class Tuteliq { if (!incidentId) { throw new ValidationError('incident_id is required'); } + if (!input.moderator_external_id) { + throw new ValidationError('moderator_external_id is required'); + } if (input.action === 'reclassify' && !input.new_risk_category) { throw new ValidationError('action="reclassify" requires new_risk_category'); } @@ -2953,6 +3030,7 @@ export class Tuteliq { * incident_ids: ['abc-…', 'def-…', 'ghi-…'], * action: 'dismiss', * reason_code: 'false_positive', + * moderator_external_id: 'moderator-42', * }); * console.log(`${r.succeeded}/${r.total} dismissed`); * if (r.failed > 0) { @@ -2968,6 +3046,9 @@ export class Tuteliq { if (input.incident_ids.length > 100) { throw new ValidationError('batchReviewIncidents accepts at most 100 incidents per call'); } + if (!input.moderator_external_id) { + throw new ValidationError('moderator_external_id is required'); + } if (input.action === 'reclassify' && !input.new_risk_category) { throw new ValidationError('action="reclassify" requires new_risk_category'); } @@ -3086,6 +3167,114 @@ export class Tuteliq { const url = '/api/v1/incidents/trends' + (qs ? `?${qs}` : ''); return this.requestWithRetry('GET', url); } + + // ========================================================================= + // DSA transparency report + // ========================================================================= + + /** + * Your moderation numbers for a reporting period, laid out on the DSA + * transparency-report template. Aggregates only: no incident content. + * Test traffic is excluded. Included in current paid plans; credits scale + * with the incidents read. + * + * `format: 'csv'` returns the CSV text instead of the JSON report. + * + * @example + * ```typescript + * const report = await tuteliq.getDsaTransparencyReport({ from: '2026-01-01', to: '2026-06-30' }) + * console.log(report.scope.incidents_counted, report.unmapped_categories) + * + * const csv = await tuteliq.getDsaTransparencyReport({ from: '2026-01-01', to: '2026-06-30', format: 'csv' }) + * ``` + */ + async getDsaTransparencyReport(input: DsaTransparencyReportInput & { format: 'csv' }): Promise; + async getDsaTransparencyReport(input: DsaTransparencyReportInput & { format?: 'json' }): Promise; + async getDsaTransparencyReport(input: DsaTransparencyReportInput): Promise { + const datePattern = /^\d{4}-\d{2}-\d{2}$/; + if (!datePattern.test(input.from ?? '') || !datePattern.test(input.to ?? '')) { + throw new ValidationError('from and to are required, in YYYY-MM-DD format'); + } + const query: Record = { from: input.from, to: input.to }; + if (input.format) query.format = input.format; + const path = `/api/v1/compliance/dsa-transparency-report?${new URLSearchParams(query).toString()}`; + if (input.format === 'csv') { + return withRetry( + () => this.requestText(path), + { maxRetries: this.retries, initialDelay: this.retryDelay } + ); + } + return this.requestWithRetry('GET', path); + } + + // ========================================================================= + // Endpoint agent fleet + // ========================================================================= + + /** + * Every device's latest attestation for the org behind the API key, + * newest first, with a compliance summary. + * + * @example + * ```typescript + * const { summary } = await tuteliq.getFleet() + * console.log(`${summary.compliance_pct}% of ${summary.total} devices compliant`) + * ``` + */ + async getFleet(): Promise { + return this.requestWithRetry('GET', '/api/v1/agent/fleet'); + } + + /** + * One device's latest attestation. Throws `NotFoundError` if the device + * has never checked in for this org. + */ + async getFleetDevice(deviceId: string): Promise { + if (!deviceId) throw new ValidationError('deviceId is required'); + return this.requestWithRetry( + 'GET', + `/api/v1/agent/fleet/${encodeURIComponent(deviceId)}`, + ); + } + + // ========================================================================= + // Status (public) + // + // A 503 from these endpoints is an answer, not a failure: the service or + // component is down. These methods return it typed, and do not retry, so + // they can drive health checks and circuit breakers directly. + // ========================================================================= + + /** + * Overall API status, component health, uptime and active incidents. + * Returns `status: 'major_outage'` (HTTP 503) instead of throwing. + * + * @example + * ```typescript + * const status = await tuteliq.getStatus() + * if (status.status !== 'operational') console.warn(status.components) + * ``` + */ + async getStatus(): Promise { + return this.requestStatus('/status'); + } + + /** + * Lightweight liveness check. + */ + async ping(): Promise { + return this.requestStatus('/status/ping'); + } + + /** + * Health of one component (for example `database`, `cache`, `ocr`). + * Returns `healthy: false` (HTTP 503) instead of throwing; an unknown + * component throws `NotFoundError`. + */ + async pingComponent(component: string): Promise { + if (!component) throw new ValidationError('component is required'); + return this.requestStatus(`/status/ping/${encodeURIComponent(component)}`); + } } // Legacy export for backwards compatibility diff --git a/src/constants.ts b/src/constants.ts index 686019e..9d7a28f 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -47,6 +47,17 @@ export enum RiskCategory { UNSAFE = 'unsafe', SELF_HARM = 'self_harm', OTHER = 'other', + SOCIAL_ENGINEERING = 'social_engineering', + APP_FRAUD = 'app_fraud', + ROMANCE_SCAM = 'romance_scam', + MULE_RECRUITMENT = 'mule_recruitment', + GAMBLING_HARM = 'gambling_harm', + COERCIVE_CONTROL = 'coercive_control', + VULNERABILITY_EXPLOITATION = 'vulnerability_exploitation', + RADICALISATION = 'radicalisation', + DISTRESS_SIGNALS = 'distress_signals', + TFGBV = 'tfgbv', + SYNTHETIC_CONTENT = 'synthetic_content', } /** @@ -88,7 +99,9 @@ export enum EmotionTrend { export enum IncidentStatus { NEW = 'new', REVIEWED = 'reviewed', + ESCALATED = 'escalated', RESOLVED = 'resolved', + DISMISSED = 'dismissed', } /** diff --git a/src/index.ts b/src/index.ts index 9e8d392..9cb59fa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -142,6 +142,8 @@ export type { UsageHistoryResult, UsageByToolResult, UsageMonthlyResult, + VerificationUsage, + ApiKeyQuota, // Account types (GDPR) AccountDeletionResult, @@ -242,6 +244,49 @@ export type { ModeratorReasonCode, ReviewIncidentInput, ReviewIncidentResult, + BatchReviewIncidentsInput, + BatchReviewItemResult, + BatchReviewIncidentsResult, + + // Incidents + IncidentSource, + IncidentSeverity, + IncidentCategory, + SpeakerStance, + ListIncidentsInput, + IncidentSummaryRow, + ListIncidentsResult, + IncidentDetail, + IncidentsOverviewInput, + IncidentsOverview, + IncidentTrendsInput, + IncidentTrendsBucket, + IncidentTrends, + + // DSA transparency report + DsaTransparencyReportInput, + DsaTransparencyReport, + DsaAvailability, + DsaPartialReason, + DsaReportField, + DsaReportSection, + DsaModerationCounts, + DsaCategory, + DsaCategoryRow, + DsaUnmappedRow, + DsaMappingRow, + + // Agent fleet + AttestationRuleResult, + FleetDevice, + FleetResult, + FleetDeviceResult, + + // Status + StatusComponent, + ApiStatus, + PingResult, + ComponentPing, // Legacy type aliases (backwards compatibility) DetectBullyingRequest, diff --git a/src/types/account.ts b/src/types/account.ts index 56b5718..c5e172c 100644 --- a/src/types/account.ts +++ b/src/types/account.ts @@ -22,6 +22,10 @@ export interface AccountExportResult { exportedAt: string; /** All user data grouped by collection */ data: Record; + /** Collections that could not be read in full. Request the export again later. */ + incompleteCollections?: string[]; + /** Collections whose completeness could not be confirmed. */ + unverifiedCollections?: string[]; } // ============================================================================= @@ -48,15 +52,19 @@ export interface ConsentRecord { /** Consent record ID */ id: string; /** User ID */ - user_id: string; + userId: string; /** Type of consent */ consent_type: ConsentType; /** Current status */ status: ConsentStatus; /** Policy version */ version: string; + /** Hashed origin of the consent action, when stored */ + ip_hash?: string; /** When the consent was recorded */ created_at: string; + /** @deprecated The API sends this as `userId`. */ + user_id?: string; } export interface ConsentStatusResult { @@ -108,13 +116,24 @@ export interface AuditLogEntry { /** Audit log entry ID */ id: string; /** User ID */ - user_id: string; + userId: string; /** Action that was performed */ action: AuditAction; - /** Additional details */ - details?: Record; + /** Hashed origin of the request, when stored */ + ip_hash?: string; + /** User agent of the request, when stored */ + user_agent?: string; + /** + * Additional details as a string. Stored encrypted: when `_encrypted` is + * true this may be ciphertext rather than readable JSON. + */ + details?: string; + /** Whether `details` is stored encrypted */ + _encrypted: boolean; /** When the action occurred */ created_at: string; + /** @deprecated The API sends this as `userId`. */ + user_id?: string; } export interface AuditLogsResult { @@ -129,6 +148,25 @@ export interface GetAuditLogsOptions { limit?: number; } +// ============================================================================= +// API key quota +// ============================================================================= + +/** Result from `GET /api/v1/account/api-keys/quota`. */ +export interface ApiKeyQuota { + /** Current tier */ + tier: string; + /** API keys currently active on the account */ + active_keys: number; + /** Maximum active keys for the tier, or `'unlimited'` */ + max_keys: number | 'unlimited'; + /** Whether another key can be created now */ + can_create: boolean; + links: { + upgrade: string; + }; +} + // ============================================================================= // Breach Management (GDPR Article 33/34) // ============================================================================= diff --git a/src/types/audit.ts b/src/types/audit.ts index 09fe4f5..ebb8f80 100644 --- a/src/types/audit.ts +++ b/src/types/audit.ts @@ -1,3 +1,5 @@ +import type { IncidentSeverity, IncidentCategory } from './incidents.js'; + // ============================================================================= // EU AI Act Art 12 audit receipts // ============================================================================= @@ -34,6 +36,14 @@ export interface AuditReceiptPayload { retention_class: RetentionClass; model?: { id: string; version?: string; prompt_template_version?: string }; thresholds_applied?: Record; + /** + * Compact, content-free description of the decision: no identifier, no + * content and no timestamp, identical for any two detections with the + * same harm shape. + */ + harm_descriptor?: string; + /** Your own pseudonymous reference for this detection, echoed back verbatim. */ + external_ref?: string; biometric?: { match_result?: 'matched' | 'no_match' | 'inconclusive'; document_type?: string; @@ -42,7 +52,7 @@ export interface AuditReceiptPayload { moderator_review?: { target_request_id: string; target_incident_id: string; - action: 'confirm' | 'downgrade' | 'escalate' | 'reclassify' | 'dismiss'; + action: ModeratorAction; reason_code: string; moderator_external_id?: string; original?: { risk_category?: string; risk_level?: string }; @@ -97,9 +107,9 @@ export interface ReviewIncidentInput { /** Free-form comment; encrypted with the customer's registered public key when E2E is enabled. */ reason_comment?: string; /** New risk level (required for `downgrade` / `escalate`, optional for `reclassify`). */ - new_risk_level?: string; + new_risk_level?: IncidentSeverity; /** New category (required for `reclassify`). */ - new_risk_category?: string; + new_risk_category?: IncidentCategory; /** * Identifier of the moderator making the decision. **Required.** * @@ -136,9 +146,10 @@ export interface BatchReviewIncidentsInput { action: ModeratorAction; reason_code: ModeratorReasonCode; reason_comment?: string; - new_risk_level?: string; - new_risk_category?: string; - moderator_external_id?: string; + new_risk_level?: IncidentSeverity; + new_risk_category?: IncidentCategory; + /** Identifier of the moderator making the decision. **Required**; see `ReviewIncidentInput`. */ + moderator_external_id: string; retention_class?: RetentionClass; } diff --git a/src/types/compliance.ts b/src/types/compliance.ts new file mode 100644 index 0000000..24fb0c9 --- /dev/null +++ b/src/types/compliance.ts @@ -0,0 +1,99 @@ +// ============================================================================= +// DSA transparency report data +// ============================================================================= + +export interface DsaTransparencyReportInput { + /** First day of the reporting period, YYYY-MM-DD (UTC), included */ + from: string; + /** Last day of the reporting period, YYYY-MM-DD (UTC), included. At most 366 days after `from`. */ + to: string; + /** `json` (default) returns a `DsaTransparencyReport`; `csv` returns the CSV text */ + format?: 'json' | 'csv'; +} + +/** Whether Tuteliq holds what a template indicator needs. */ +export type DsaAvailability = 'computed' | 'partial' | 'not_held_by_tuteliq'; + +/** What is missing for a `partial` indicator. */ +export type DsaPartialReason = + | 'restriction_applied_by_platform' + | 'human_reviewed_sample_only' + | 'no_human_reviews' + | 'latest_decision_time_only'; + +/** One template indicator. A `not_held_by_tuteliq` value is always null: your platform supplies it. */ +export type DsaReportField = + | { indicator: string; availability: 'computed'; value: number | string; reason: null } + | { indicator: string; availability: 'partial'; value: number | null; reason: DsaPartialReason; supporting_metrics?: string[] } + | { indicator: string; availability: 'not_held_by_tuteliq'; value: null; reason: 'not_held_by_tuteliq' }; + +export interface DsaReportSection { + section: string; + title: string; + legal_basis: string; + availability: DsaAvailability; + fields: DsaReportField[]; +} + +export interface DsaModerationCounts { + automated_detections: number; + human_reviewed: number; + upheld_after_human_review: number; + dismissed_after_human_review: number; + /** Closed by a moderator with no verdict on the detection */ + closed_without_verdict: number; + awaiting_human_review: number; + policy_actions: Record<'block' | 'flag' | 'escalate' | 'notify' | 'log_only', number>; +} + +/** A template category or sub-category. */ +export interface DsaCategory { + /** `STATEMENT_CATEGORY_*` or `KEYWORD_*` code */ + code: string; + label: string; + template_ref: string; +} + +export type DsaCategoryRow = { + dsa_category: DsaCategory; + tuteliq_categories: string[]; + counts: DsaModerationCounts; +} & ( + | { dsa_subcategory: DsaCategory & { category: string }; subcategory_reason: null } + | { dsa_subcategory: null; subcategory_reason: 'subcategory_not_distinguished_by_tuteliq' } +); + +/** Detections with no single template category, for you to classify. */ +export interface DsaUnmappedRow { + tuteliq_category: string; + reason: 'no_single_dsa_category'; + counts: DsaModerationCounts; +} + +export interface DsaMappingRow { + tuteliq_category: string; + dsa_category: string | null; + dsa_subcategory: string | null; + reason: 'no_single_dsa_category' | null; +} + +/** Result from `GET /api/v1/compliance/dsa-transparency-report` (JSON). Aggregates only. */ +export interface DsaTransparencyReport { + report: 'dsa_transparency_report_data'; + template: { regulation: string; annex: string; categories: string }; + reporting_period: { from: string; to: string }; + generated_at: string; + notice: string; + /** Test traffic is excluded from the counts */ + scope: { incidents_scanned: number; incidents_counted: number; test_incidents_excluded: number }; + sections: DsaReportSection[]; + own_initiative_by_category: DsaCategoryRow[]; + unmapped_categories: DsaUnmappedRow[]; + supporting_data: { + totals: DsaModerationCounts; + median_hours_to_latest_human_decision: DsaReportField; + decision_time_sample_size: number; + }; + category_mapping: DsaMappingRow[]; + credits_used: number; +} diff --git a/src/types/fleet.ts b/src/types/fleet.ts new file mode 100644 index 0000000..d2111dc --- /dev/null +++ b/src/types/fleet.ts @@ -0,0 +1,45 @@ +// ============================================================================= +// Endpoint agent fleet (device attestations) +// ============================================================================= + +/** One evaluated policy rule in a device attestation. */ +export interface AttestationRuleResult { + /** The posture check that was evaluated */ + rule: Record; + passed: boolean; + detail?: string; +} + +/** A device's latest attestation, scoped to the org behind the API key. */ +export interface FleetDevice { + device_id: string; + agent_version: string; + platform: string; + policy_id: string; + /** Whether every rule passed */ + passed: boolean; + results: AttestationRuleResult[]; + attested_at: string; + org_id: string; + expires_at: string; +} + +/** Result from `GET /api/v1/agent/fleet`. */ +export interface FleetResult { + /** Each device's latest attestation, newest first */ + devices: FleetDevice[]; + summary: { + total: number; + compliant: number; + non_compliant: number; + /** 0-100, one decimal place */ + compliance_pct: number; + }; + credits_used: number; +} + +/** Result from `GET /api/v1/agent/fleet/{deviceId}`. */ +export interface FleetDeviceResult { + device: FleetDevice; + credits_used: number; +} diff --git a/src/types/incidents.ts b/src/types/incidents.ts index 924000e..ba0b593 100644 --- a/src/types/incidents.ts +++ b/src/types/incidents.ts @@ -9,16 +9,46 @@ */ export type IncidentSource = 'text' | 'voice' | 'image' | 'video' | 'video_stream'; -export type IncidentSeverity = 'low' | 'medium' | 'high' | 'critical'; -export type IncidentStatus = 'new' | 'reviewing' | 'escalated' | 'resolved' | 'dismissed'; +/** An incident's stored `risk_level`. Detection responses and webhooks spell `moderate` as `medium`. */ +export type IncidentSeverity = 'low' | 'moderate' | 'high' | 'critical'; +/** An incident's moderation `status`. */ +export type IncidentStatus = 'new' | 'reviewed' | 'escalated' | 'resolved' | 'dismissed'; +/** An incident's `risk_category`. */ +export type IncidentCategory = + | 'bullying' + | 'grooming' + | 'unsafe' + | 'self_harm' + | 'other' + | 'social_engineering' + | 'app_fraud' + | 'romance_scam' + | 'mule_recruitment' + | 'gambling_harm' + | 'coercive_control' + | 'vulnerability_exploitation' + | 'radicalisation' + | 'distress_signals' + | 'tfgbv' + | 'synthetic_content'; + +/** Whether the speaker is doing what was detected or reporting someone else doing it. */ +export type SpeakerStance = 'deploying' | 'describing_third_party' | 'unclear'; export interface ListIncidentsInput { /** Filter by risk_category (e.g. 'bullying', 'grooming', 'synthetic_content'). */ - category?: string; - /** Filter by risk_level. */ - severity?: IncidentSeverity; - /** Filter by moderation status. */ - status?: IncidentStatus; + category?: IncidentCategory | (string & {}); + /** + * Filter by risk_level. `medium` is accepted as another spelling of + * `moderate` and returns the same incidents. + */ + severity?: IncidentSeverity | 'medium'; + /** + * Filter by moderation status. `reviewing` is deprecated: it is still + * accepted, but no incident holds it, so it returns nothing. Use `new` + * for incidents nobody has acted on and `reviewed` for ones a moderator has. + */ + status?: IncidentStatus | 'reviewing'; /** Filter by source modality. */ source?: IncidentSource; /** ISO 8601 inclusive lower bound on created_at. */ @@ -45,15 +75,34 @@ export interface ListIncidentsInput { export interface IncidentSummaryRow { id: string; - risk_category: string; - risk_level: string; + risk_category: IncidentCategory; + risk_level: IncidentSeverity; confidence_score: number | null; detected_patterns: string[]; + /** Routing keys for this incident, on list rows as well as detail. */ + recommended_actions: string[]; platform: string | null; language: string | null; - source: string; + source: IncidentSource; file_id: string | null; - status: string; + status: IncidentStatus; + /** + * Your policy rules' verdict on this incident, in the same shape as the + * detection response's `policy_action`. Null when no rule matched. + */ + policy_action: Record | null; + /** Advisory next status for a moderator. Never applied automatically. */ + recommended_status: IncidentStatus | null; + /** True when urgency was held back pending independent corroboration. Null when no cap applied. */ + escalation_capped: boolean | null; + escalation_capped_reason: string | null; + /** Whether the speaker is doing what was detected or reporting it. Null when not assessed. */ + speaker_stance: SpeakerStance | null; + /** True when the action was lowered because the speaker is reporting a third party. Null when no cap applied. */ + action_capped: boolean | null; + action_capped_reason: string | null; + /** True when the incident came from a non-production API key (test traffic). */ + is_test: boolean | null; external_id: string | null; customer_id: string | null; created_at: string; @@ -84,7 +133,6 @@ export interface IncidentDetail extends IncidentSummaryRow { visual_confidence?: number | null; contains_text?: boolean | null; contains_faces?: boolean | null; - recommended_actions?: string[]; emotional_indicators?: string[]; review?: Record | null; /** @@ -110,7 +158,7 @@ export interface IncidentsOverviewInput { export interface IncidentsOverview { timeframe: { from: string; to: string }; total_incidents: number; - /** High/critical incidents in `new` or `reviewing` status — actionable triage queue size. */ + /** High/critical incidents nobody has acted on yet: the actionable triage queue size. */ requires_review_count: number; last_24h_count: number; last_7d_count: number; diff --git a/src/types/index.ts b/src/types/index.ts index 8d5922b..534bad9 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -8,6 +8,9 @@ export * from './account.js'; export * from './media.js'; export * from './webhooks.js'; export * from './pricing.js'; +export * from './status.js'; +export * from './fleet.js'; +export * from './compliance.js'; export * from './voice-stream.js'; export * from './detection.js'; export * from './verification.js'; @@ -94,23 +97,50 @@ export interface RateLimitInfo { // Usage Types // ============================================================================= +/** + * GET /api/v1/usage/summary: one day's request counts for this API key, with + * the tier's limits. + * + * Earlier versions of this type declared billing-period fields the API never + * sent on this endpoint; they are kept below, deprecated, so existing code + * still compiles. Billing-period figures are on `getUsageMonthly()`. + */ export interface UsageSummary { - /** Messages used in current billing period */ - messages_used: number; - /** Monthly message limit */ - message_limit: number; - /** Purchased credits available */ - purchased_credits: number; - /** Total available (limit + credits) */ - total_available: number; - /** Usage percentage (0-100) */ - usage_percentage: number; - /** Billing period start date (ISO string) */ - period_start: string; - /** Billing period end date (ISO string) */ - period_end: string; - /** Days remaining in billing period */ - days_remaining: number; + apiKeyId: string; + /** Current tier */ + tier: string; + /** The day these counts cover, YYYY-MM-DD */ + date: string; + usage: { + totalRequests: number; + successRequests: number; + errorRequests: number; + }; + quota: { + requestsPerMinute: number; + /** -1 = no limit */ + requestsPerMonth: number; + /** -1 on paid tiers: there is no daily limit. A sandbox key reports its daily cap. */ + requestsPerDay: number; + /** -1 when there is no daily limit */ + remainingToday: number; + }; + /** @deprecated Never sent by this endpoint. Use `getUsageMonthly().usage.used`. */ + messages_used?: number; + /** @deprecated Never sent by this endpoint. Use `getUsageMonthly().usage.limit`. */ + message_limit?: number; + /** @deprecated Never sent by this endpoint. */ + purchased_credits?: number; + /** @deprecated Never sent by this endpoint. Use `getUsageMonthly().usage.limit`. */ + total_available?: number; + /** @deprecated Never sent by this endpoint. Use `getUsageMonthly().usage.percent_used`. */ + usage_percentage?: number; + /** @deprecated Never sent by this endpoint. Use `getUsageMonthly().billing.current_period_start`. */ + period_start?: string; + /** @deprecated Never sent by this endpoint. Use `getUsageMonthly().billing.current_period_end`. */ + period_end?: string; + /** @deprecated Never sent by this endpoint. Use `getUsageMonthly().billing.days_remaining`. */ + days_remaining?: number; } /** @@ -212,6 +242,35 @@ export interface UsageMonthlyResult { pricing: string; buy_credits: string; }; + /** + * Present only on an enterprise verification plan. `usage.used` and + * `usage.limit` then count verifications rather than credits, and + * `tier_display_name` is the plan name. + */ + verification?: VerificationUsage; +} + +/** Verification allowance for the current period (enterprise verification plans). */ +export interface VerificationUsage { + /** YYYY-MM */ + period: string; + usage_timezone: string; + next_period_start: string; + included_per_month: number; + included_used: number; + /** Verifications taken from packs this period */ + pack_used: number; + /** Remaining balance across your verification packs */ + packs_remaining: number; + remaining: number; + limit_reached: boolean; + /** Completed verifications this period */ + total: number; + /** Verifications started and not finished yet */ + in_progress: number; + sandbox_count: number; + /** Per API key: completed and sandbox counts */ + by_key: Record; } // ============================================================================= diff --git a/src/types/pricing.ts b/src/types/pricing.ts index 3d3142d..677b017 100644 --- a/src/types/pricing.ts +++ b/src/types/pricing.ts @@ -15,11 +15,15 @@ export interface PricingPlan { /** Feature list */ features: string[]; /** Whether this is the recommended plan */ - is_popular: boolean; + isPopular: boolean; /** Call-to-action text */ cta: string; /** Call-to-action link */ - cta_link: string; + ctaLink: string; + /** @deprecated The API sends this as `isPopular`. */ + is_popular?: boolean; + /** @deprecated The API sends this as `ctaLink`. */ + cta_link?: string; } /** Result from `GET /api/v1/pricing`. */ diff --git a/src/types/status.ts b/src/types/status.ts new file mode 100644 index 0000000..021a794 --- /dev/null +++ b/src/types/status.ts @@ -0,0 +1,65 @@ +// ============================================================================= +// Status and ping (public, no authentication) +// ============================================================================= + +/** Health of one component on the status endpoint. */ +export interface StatusComponent { + status: 'operational' | 'degraded' | 'outage'; + latency_ms?: number; + message?: string; +} + +/** + * Result from `GET /status`. + * + * The API answers HTTP 503 when `status` is `major_outage`; the SDK returns + * that answer like any other instead of throwing. + */ +export interface ApiStatus { + status: 'operational' | 'degraded' | 'major_outage'; + updated_at: string; + components: { + api: StatusComponent; + database: StatusComponent; + cache: StatusComponent; + analysis_engine: StatusComponent; + }; + /** Uptime percentages as strings, e.g. `"99.95"` */ + uptime: { + last_24h: string; + last_7d: string; + last_30d: string; + }; + incidents: { + active: number; + recent: Array<{ + id: string; + title: string; + status: string; + created_at: string; + }>; + }; +} + +/** Result from `GET /status/ping`. */ +export interface PingResult { + status: 'ok'; + /** Unix time in milliseconds */ + timestamp: number; +} + +/** + * Result from `GET /status/ping/{component}`. + * + * The API answers HTTP 503 when the component is failing; the SDK returns + * that answer with `healthy: false` instead of throwing. + */ +export interface ComponentPing { + component: string; + healthy: boolean; + latency_ms?: number; + /** True when the probe was not run (the component is not configured on this deployment) */ + skipped?: boolean; + message?: string; + timestamp: string; +} diff --git a/tests/account.test.ts b/tests/account.test.ts new file mode 100644 index 0000000..4bc703c --- /dev/null +++ b/tests/account.test.ts @@ -0,0 +1,532 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Tuteliq } from '../src/client.js'; +import { ValidationError, NotFoundError, TierAccessError } from '../src/errors.js'; +import { IncidentStatus as IncidentStatusEnum, RiskCategory as RiskCategoryEnum, RiskLevel } from '../src/constants.js'; +import type { + UsageSummary, + UsageMonthlyResult, + PricingPlan, + ConsentRecord, + AuditLogEntry, + AuditReceiptPayload, + IncidentSummaryRow, + ReviewIncidentInput, + BatchReviewIncidentsInput, + ListIncidentsInput, + ApiKeyQuota, + ApiStatus, + ComponentPing, + DsaTransparencyReport, + FleetResult, + FleetDeviceResult, +} from '../src/types/index.js'; + +/** + * Pins the account and platform types to the shapes the API sends. + * + * The fixtures below are typed with the SDK's own interfaces, so a field the + * API sends that the type does not declare (or a stale field the type still + * requires) fails `tsc`. The runtime assertions pin the request path and that + * the response reaches the caller unchanged. + */ + +function mockFetchResponse( + data: unknown, + options: { ok?: boolean; status?: number; headers?: Record; text?: string } = {}, +) { + const { ok = true, status = 200, headers = {}, text } = options; + return { + ok, + status, + json: async () => data, + text: async () => text ?? JSON.stringify(data), + headers: { + get: (name: string) => headers[name.toLowerCase()] || null, + }, + } as Response; +} + +describe('Tuteliq: account and platform', () => { + let client: Tuteliq; + + beforeEach(() => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 0 }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // --------------------------------------------------------------------- + // Usage + // --------------------------------------------------------------------- + + describe('getUsageSummary', () => { + const wire: UsageSummary = { + apiKeyId: 'key_1', + tier: 'build', + date: '2026-11-07', + usage: { totalRequests: 12, successRequests: 11, errorRequests: 1 }, + quota: { requestsPerMinute: 120, requestsPerMonth: 50000, requestsPerDay: -1, remainingToday: -1 }, + }; + + it('returns the daily summary with nested usage and quota', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse(wire)); + const summary = await client.getUsageSummary(); + expect(summary.usage.totalRequests).toBe(12); + expect(summary.quota.remainingToday).toBe(-1); + }); + + it('passes the date as a query parameter', async () => { + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse(wire)); + await client.getUsageSummary('2026-11-07'); + expect(fetchSpy.mock.calls[0][0]).toBe('https://api.tuteliq.ai/api/v1/usage/summary?date=2026-11-07'); + }); + + it('omits the query string without a date', async () => { + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse(wire)); + await client.getUsageSummary(); + expect(fetchSpy.mock.calls[0][0]).toBe('https://api.tuteliq.ai/api/v1/usage/summary'); + }); + }); + + describe('getUsageMonthly', () => { + it('carries the verification block on an enterprise verification plan', async () => { + const wire: UsageMonthlyResult = { + tier: 'enterprise', + tier_display_name: 'Verification Enterprise', + billing: { current_period_start: '2026-11-01', current_period_end: '2026-11-30', days_remaining: 22 }, + usage: { used: 40, limit: 1000, remaining: 960, percent_used: 4 }, + rate_limit: { requests_per_minute: 600 }, + recommendations: null, + links: { dashboard: 'd', pricing: 'p', buy_credits: 'b' }, + verification: { + period: '2026-11', + usage_timezone: 'UTC', + next_period_start: '2026-12-01T00:00:00.000Z', + included_per_month: 1000, + included_used: 40, + pack_used: 0, + packs_remaining: 0, + remaining: 960, + limit_reached: false, + total: 38, + in_progress: 2, + sandbox_count: 5, + by_key: { key_1: { total: 38, sandbox: 5 } }, + }, + }; + vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse(wire)); + const monthly = await client.getUsageMonthly(); + expect(monthly.verification?.in_progress).toBe(2); + expect(monthly.verification?.by_key.key_1.sandbox).toBe(5); + }); + }); + + // --------------------------------------------------------------------- + // Pricing, consent, audit logs: field names on the wire + // --------------------------------------------------------------------- + + describe('getPricing', () => { + it('reads isPopular and ctaLink as the API spells them', async () => { + const plan: PricingPlan = { + name: 'Build', + price: '$99', + period: '/month', + description: 'For teams shipping', + features: ['All detectors'], + isPopular: true, + cta: 'Start 14-day trial', + ctaLink: 'https://tuteliq.ai/signup', + }; + vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse({ plans: [plan] })); + const { plans } = await client.getPricing(); + expect(plans[0].isPopular).toBe(true); + expect(plans[0].ctaLink).toBe('https://tuteliq.ai/signup'); + }); + }); + + describe('getConsentStatus', () => { + it('reads userId and ip_hash as the API sends them', async () => { + const record: ConsentRecord = { + id: 'c1', + userId: 'u1', + consent_type: 'data_processing', + status: 'granted', + version: '1.0', + ip_hash: 'abc', + created_at: '2026-11-01T00:00:00.000Z', + }; + vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse({ consents: [record] })); + const { consents } = await client.getConsentStatus(); + expect(consents[0].userId).toBe('u1'); + expect(consents[0].ip_hash).toBe('abc'); + }); + }); + + describe('getAuditLogs', () => { + it('reads userId, string details and the encryption flag', async () => { + const entry: AuditLogEntry = { + id: 'a1', + userId: 'u1', + action: 'data_export', + ip_hash: 'abc', + user_agent: 'curl/8', + details: 'ciphertext', + _encrypted: true, + created_at: '2026-11-01T00:00:00.000Z', + }; + vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse({ audit_logs: [entry] })); + const { audit_logs } = await client.getAuditLogs(); + expect(audit_logs[0].userId).toBe('u1'); + expect(audit_logs[0]._encrypted).toBe(true); + expect(typeof audit_logs[0].details).toBe('string'); + }); + }); + + describe('getApiKeyQuota', () => { + it('hits GET /api/v1/account/api-keys/quota', async () => { + const wire: ApiKeyQuota = { + tier: 'enterprise', + active_keys: 3, + max_keys: 'unlimited', + can_create: true, + links: { upgrade: 'https://tuteliq.ai/dashboard/billing' }, + }; + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse(wire)); + const quota = await client.getApiKeyQuota(); + expect(fetchSpy.mock.calls[0][0]).toBe('https://api.tuteliq.ai/api/v1/account/api-keys/quota'); + expect(quota.max_keys).toBe('unlimited'); + }); + }); + + // --------------------------------------------------------------------- + // Incidents + // --------------------------------------------------------------------- + + describe('incident enums', () => { + it('IncidentStatus names every status an incident can hold', () => { + expect(Object.values(IncidentStatusEnum).sort()).toEqual( + ['dismissed', 'escalated', 'new', 'resolved', 'reviewed'], + ); + }); + + it('RiskCategory names every incident category', () => { + expect(Object.values(RiskCategoryEnum).sort()).toEqual([ + 'app_fraud', 'bullying', 'coercive_control', 'distress_signals', 'gambling_harm', + 'grooming', 'mule_recruitment', 'other', 'radicalisation', 'romance_scam', 'self_harm', + 'social_engineering', 'synthetic_content', 'tfgbv', 'unsafe', 'vulnerability_exploitation', + ]); + }); + + it('RiskLevel stores moderate, not medium', () => { + expect(Object.values(RiskLevel)).toEqual(['low', 'moderate', 'high', 'critical']); + }); + }); + + describe('listIncidents', () => { + it('returns rows with the policy, recommendation, cap and test-traffic fields', async () => { + const row: IncidentSummaryRow = { + id: 'i1', + risk_category: 'grooming', + risk_level: 'moderate', + confidence_score: 0.9, + detected_patterns: [], + recommended_actions: ['review'], + platform: null, + language: 'en', + source: 'text', + file_id: null, + status: 'reviewed', + policy_action: { action: 'flag' }, + recommended_status: 'escalated', + escalation_capped: null, + escalation_capped_reason: null, + speaker_stance: 'describing_third_party', + action_capped: true, + action_capped_reason: 'third_party_report', + is_test: true, + external_id: null, + customer_id: null, + created_at: '2026-11-01T00:00:00.000Z', + }; + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse({ incidents: [row], next_cursor: null, total_returned: 1 }), + ); + const page = await client.listIncidents(); + expect(page.incidents[0].is_test).toBe(true); + expect(page.incidents[0].recommended_actions).toEqual(['review']); + }); + + it('still accepts the older filter spellings', async () => { + const input: ListIncidentsInput = { severity: 'medium', status: 'reviewing' }; + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse({ incidents: [], next_cursor: null, total_returned: 0 }), + ); + await client.listIncidents(input); + expect(fetchSpy.mock.calls[0][0]).toBe('https://api.tuteliq.ai/api/v1/incidents?severity=medium&status=reviewing'); + }); + }); + + describe('incident review', () => { + it('types new_risk_level and new_risk_category with the values the API accepts', () => { + const input: ReviewIncidentInput = { + action: 'reclassify', + reason_code: 'incorrect_category', + new_risk_level: 'moderate', + new_risk_category: 'romance_scam', + moderator_external_id: 'mod-1', + }; + const stale: ReviewIncidentInput = { + action: 'downgrade', + reason_code: 'insufficient_severity', + // @ts-expect-error 'medium' is not a risk level the API stores + new_risk_level: 'medium', + moderator_external_id: 'mod-1', + }; + expect(input.new_risk_category).toBe('romance_scam'); + expect(stale.new_risk_level).toBe('medium'); + }); + + it('batchReviewIncidents requires moderator_external_id before calling the API', async () => { + const fetchSpy = vi.spyOn(global, 'fetch'); + // @ts-expect-error moderator_external_id is required + const input: BatchReviewIncidentsInput = { incident_ids: ['i1'], action: 'dismiss', reason_code: 'false_positive' }; + await expect(client.batchReviewIncidents(input)).rejects.toThrow(ValidationError); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('reviewIncident requires moderator_external_id before calling the API', async () => { + const fetchSpy = vi.spyOn(global, 'fetch'); + const input = { action: 'dismiss', reason_code: 'false_positive' } as ReviewIncidentInput; + await expect(client.reviewIncident('i1', input)).rejects.toThrow(ValidationError); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + }); + + describe('getAuditReceipt', () => { + it('reads resolve/reopen reviews, harm_descriptor and external_ref', async () => { + const payload: AuditReceiptPayload = { + v: 1, + request_id: 'r1', + timestamp: '2026-11-01T00:00:00.000Z', + endpoint: '/api/v1/incidents/i1/review', + decision: 'resolve', + deployer: 'd1', + retention_class: 'limited-risk', + harm_descriptor: 'tlq:h1/example', + external_ref: 'case-123', + moderator_review: { + target_request_id: 'r0', + target_incident_id: 'i1', + action: 'reopen', + reason_code: 'other', + }, + }; + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse({ payload, signature: { algorithm: 'HMAC_SHA256', key_id: 'k', signature: 's', signed_at: 't' }, canonical: '{}' }), + ); + const receipt = await client.getAuditReceipt('r1'); + expect(receipt.payload.external_ref).toBe('case-123'); + expect(receipt.payload.moderator_review?.action).toBe('reopen'); + }); + }); + + // --------------------------------------------------------------------- + // DSA transparency report + // --------------------------------------------------------------------- + + describe('getDsaTransparencyReport', () => { + const counts = { + automated_detections: 10, + human_reviewed: 4, + upheld_after_human_review: 3, + dismissed_after_human_review: 1, + closed_without_verdict: 0, + awaiting_human_review: 6, + policy_actions: { block: 1, flag: 2, escalate: 0, notify: 0, log_only: 0 }, + }; + const report: DsaTransparencyReport = { + report: 'dsa_transparency_report_data', + template: { regulation: 'r', annex: 'a', categories: 'c' }, + reporting_period: { from: '2026-01-01', to: '2026-06-30' }, + generated_at: '2026-11-08T00:00:00.000Z', + notice: 'n', + scope: { incidents_scanned: 12, incidents_counted: 10, test_incidents_excluded: 2 }, + sections: [{ + section: 's', + title: 't', + legal_basis: 'l', + availability: 'partial', + fields: [ + { indicator: 'x', availability: 'computed', value: 3, reason: null }, + { indicator: 'y', availability: 'partial', value: null, reason: 'no_human_reviews' }, + { indicator: 'z', availability: 'not_held_by_tuteliq', value: null, reason: 'not_held_by_tuteliq' }, + ], + }], + own_initiative_by_category: [{ + dsa_category: { code: 'STATEMENT_CATEGORY_PROTECTION_OF_MINORS', label: 'Protection of minors', template_ref: 'Category 9' }, + tuteliq_categories: ['grooming'], + counts, + dsa_subcategory: null, + subcategory_reason: 'subcategory_not_distinguished_by_tuteliq', + }], + unmapped_categories: [{ tuteliq_category: 'other', reason: 'no_single_dsa_category', counts }], + supporting_data: { + totals: counts, + median_hours_to_latest_human_decision: { indicator: 'm', availability: 'computed', value: 5, reason: null }, + decision_time_sample_size: 4, + }, + category_mapping: [{ tuteliq_category: 'grooming', dsa_category: 'STATEMENT_CATEGORY_PROTECTION_OF_MINORS', dsa_subcategory: null, reason: null }], + credits_used: 1, + }; + + it('returns the JSON report for a period', async () => { + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse(report)); + const result = await client.getDsaTransparencyReport({ from: '2026-01-01', to: '2026-06-30' }); + expect(fetchSpy.mock.calls[0][0]).toBe( + 'https://api.tuteliq.ai/api/v1/compliance/dsa-transparency-report?from=2026-01-01&to=2026-06-30', + ); + expect(result.scope.test_incidents_excluded).toBe(2); + }); + + it('returns the CSV as raw text', async () => { + const csv = 'section,indicator,value\r\n1,x,3\r\n'; + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse(undefined, { text: csv, headers: { 'content-type': 'text/csv; charset=utf-8' } }), + ); + const result: string = await client.getDsaTransparencyReport({ from: '2026-01-01', to: '2026-06-30', format: 'csv' }); + expect(fetchSpy.mock.calls[0][0]).toBe( + 'https://api.tuteliq.ai/api/v1/compliance/dsa-transparency-report?from=2026-01-01&to=2026-06-30&format=csv', + ); + expect(result).toBe(csv); + }); + + it('surfaces a plan refusal on the CSV path as a typed error', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse({ error: { code: 'ENDPOINT_NOT_IN_PLAN', message: 'Not in plan' } }, { ok: false, status: 403 }), + ); + await expect( + client.getDsaTransparencyReport({ from: '2026-01-01', to: '2026-06-30', format: 'csv' }), + ).rejects.toThrow(TierAccessError); + }); + + it('validates the period dates', async () => { + await expect(client.getDsaTransparencyReport({ from: '2026-1-1', to: '2026-06-30' })).rejects.toThrow(ValidationError); + await expect(client.getDsaTransparencyReport({ from: '2026-01-01', to: '' })).rejects.toThrow(ValidationError); + }); + }); + + // --------------------------------------------------------------------- + // Status and ping: a 503 is an answer, not an error + // --------------------------------------------------------------------- + + describe('status', () => { + beforeEach(() => { + client = new Tuteliq('test-api-key', { timeout: 5000, retries: 2, retryDelay: 1 }); + }); + + const outage: ApiStatus = { + status: 'major_outage', + updated_at: '2026-11-08T00:00:00.000Z', + components: { + api: { status: 'operational', latency_ms: 0 }, + database: { status: 'outage', message: 'unreachable' }, + cache: { status: 'outage' }, + analysis_engine: { status: 'operational' }, + }, + uptime: { last_24h: '99.90', last_7d: '99.95', last_30d: '99.99' }, + incidents: { active: 0, recent: [] }, + }; + + it('getStatus returns a major outage (HTTP 503) without throwing or retrying', async () => { + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue( + mockFetchResponse(outage, { ok: false, status: 503 }), + ); + const status = await client.getStatus(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy.mock.calls[0][0]).toBe('https://api.tuteliq.ai/status'); + expect(status.status).toBe('major_outage'); + expect(status.components.database.status).toBe('outage'); + }); + + it('ping hits GET /status/ping', async () => { + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse({ status: 'ok', timestamp: 1762560000000 }), + ); + const pong = await client.ping(); + expect(fetchSpy.mock.calls[0][0]).toBe('https://api.tuteliq.ai/status/ping'); + expect(pong.status).toBe('ok'); + }); + + it('pingComponent returns an unhealthy component (HTTP 503) without throwing or retrying', async () => { + const wire: ComponentPing = { + component: 'ocr', + healthy: false, + latency_ms: 5001, + message: 'timeout', + timestamp: '2026-11-08T00:00:00.000Z', + }; + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue( + mockFetchResponse(wire, { ok: false, status: 503 }), + ); + const result = await client.pingComponent('ocr'); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy.mock.calls[0][0]).toBe('https://api.tuteliq.ai/status/ping/ocr'); + expect(result.healthy).toBe(false); + }); + + it('pingComponent throws NotFoundError for an unknown component', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + mockFetchResponse({ component: 'nope', error: 'unknown_component', available: ['ocr'] }, { ok: false, status: 404 }), + ); + await expect(client.pingComponent('nope')).rejects.toThrow(NotFoundError); + }); + + it('pingComponent validates the component name', async () => { + await expect(client.pingComponent('')).rejects.toThrow(ValidationError); + }); + }); + + // --------------------------------------------------------------------- + // Agent fleet reads + // --------------------------------------------------------------------- + + describe('fleet', () => { + const device = { + device_id: 'dev-1', + agent_version: '1.0.0', + platform: 'darwin', + policy_id: 'pol-1', + passed: true, + results: [{ rule: { kind: 'os_version' }, passed: true }], + attested_at: '2026-11-01T00:00:00.000Z', + org_id: 'org-1', + expires_at: '2027-01-30T00:00:00.000Z', + }; + + it('getFleet hits GET /api/v1/agent/fleet', async () => { + const wire: FleetResult = { + devices: [device], + summary: { total: 1, compliant: 1, non_compliant: 0, compliance_pct: 100 }, + credits_used: 1, + }; + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse(wire)); + const fleet = await client.getFleet(); + expect(fetchSpy.mock.calls[0][0]).toBe('https://api.tuteliq.ai/api/v1/agent/fleet'); + expect(fleet.summary.compliance_pct).toBe(100); + }); + + it('getFleetDevice hits GET /api/v1/agent/fleet/:deviceId', async () => { + const wire: FleetDeviceResult = { device, credits_used: 1 }; + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockFetchResponse(wire)); + const result = await client.getFleetDevice('dev 1'); + expect(fetchSpy.mock.calls[0][0]).toBe('https://api.tuteliq.ai/api/v1/agent/fleet/dev%201'); + expect(result.device.passed).toBe(true); + }); + + it('getFleetDevice validates the device id', async () => { + await expect(client.getFleetDevice('')).rejects.toThrow(ValidationError); + }); + }); +}); diff --git a/tests/newEndpoints.test.ts b/tests/newEndpoints.test.ts index ca1e5ae..bdddd8a 100644 --- a/tests/newEndpoints.test.ts +++ b/tests/newEndpoints.test.ts @@ -167,6 +167,7 @@ describe('Tuteliq — new endpoints sync', () => { action: 'downgrade', reason_code: 'insufficient_severity', new_risk_level: 'moderate', + moderator_external_id: 'mod-1', }); const call = fetchSpy.mock.calls[0]; expect(call[0]).toBe('https://api.tuteliq.ai/api/v1/incidents/inc_1/review'); @@ -179,13 +180,13 @@ describe('Tuteliq — new endpoints sync', () => { it('rejects empty incident_id', async () => { await expect( - client.reviewIncident('', { action: 'confirm', reason_code: 'confirmed_accurate' }), + client.reviewIncident('', { action: 'confirm', reason_code: 'confirmed_accurate', moderator_external_id: 'mod-1' }), ).rejects.toThrow(ValidationError); }); it('rejects action=reclassify without new_risk_category', async () => { await expect( - client.reviewIncident('inc_1', { action: 'reclassify', reason_code: 'incorrect_category' }), + client.reviewIncident('inc_1', { action: 'reclassify', reason_code: 'incorrect_category', moderator_external_id: 'mod-1' }), ).rejects.toThrow(/new_risk_category/); }); }); From d4da492d544cdef8be055b9a01d6afc0148f9836 Mon Sep 17 00:00:00 2001 From: safenestdev Date: Sun, 8 Nov 2026 08:52:18 +0100 Subject: [PATCH 2/2] docs(account): describe incident result adjustment fields generically --- src/types/incidents.ts | 6 ++++-- tests/account.test.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/types/incidents.ts b/src/types/incidents.ts index ba0b593..059056c 100644 --- a/src/types/incidents.ts +++ b/src/types/incidents.ts @@ -93,13 +93,15 @@ export interface IncidentSummaryRow { policy_action: Record | null; /** Advisory next status for a moderator. Never applied automatically. */ recommended_status: IncidentStatus | null; - /** True when urgency was held back pending independent corroboration. Null when no cap applied. */ + /** Adjustment the API applied to this result, reported for transparency. Descriptive only. Null when it did not apply. */ escalation_capped: boolean | null; + /** Human-readable explanation of the adjustment. Null when it did not apply. */ escalation_capped_reason: string | null; /** Whether the speaker is doing what was detected or reporting it. Null when not assessed. */ speaker_stance: SpeakerStance | null; - /** True when the action was lowered because the speaker is reporting a third party. Null when no cap applied. */ + /** Adjustment the API applied to this result, reported for transparency. Descriptive only. Null when it did not apply. */ action_capped: boolean | null; + /** Human-readable explanation of the adjustment. Null when it did not apply. */ action_capped_reason: string | null; /** True when the incident came from a non-production API key (test traffic). */ is_test: boolean | null; diff --git a/tests/account.test.ts b/tests/account.test.ts index 4bc703c..7084c5a 100644 --- a/tests/account.test.ts +++ b/tests/account.test.ts @@ -244,7 +244,7 @@ describe('Tuteliq: account and platform', () => { escalation_capped_reason: null, speaker_stance: 'describing_third_party', action_capped: true, - action_capped_reason: 'third_party_report', + action_capped_reason: 'example', is_test: true, external_id: null, customer_id: null,