From d14407b408c66092004c94d15b018ce28759b73e Mon Sep 17 00:00:00 2001 From: Meet Batra Date: Mon, 10 Aug 2026 12:41:21 +0530 Subject: [PATCH 1/9] feat: scaffold ambientweather plugin --- packages/ambientweather/client.ts | 60 +++++ packages/ambientweather/endpoints/example.ts | 21 ++ packages/ambientweather/endpoints/index.ts | 7 + packages/ambientweather/endpoints/types.ts | 29 +++ packages/ambientweather/error-handlers.ts | 31 +++ packages/ambientweather/index.ts | 223 ++++++++++++++++++ packages/ambientweather/jest.config.cjs | 55 +++++ packages/ambientweather/package.json | 44 ++++ packages/ambientweather/schema.test.ts | 22 ++ packages/ambientweather/schema/database.ts | 9 + packages/ambientweather/schema/index.ts | 4 + packages/ambientweather/tsconfig.json | 20 ++ packages/ambientweather/tsup.config.ts | 15 ++ packages/ambientweather/webhooks/example.ts | 35 +++ packages/ambientweather/webhooks/index.ts | 9 + .../webhooks/oauth-tenant-link.ts | 31 +++ .../ambientweather/webhooks/tenant-matcher.ts | 25 ++ packages/ambientweather/webhooks/types.ts | 66 ++++++ packages/corsair/core/constants.ts | 3 + pnpm-lock.yaml | 24 ++ 20 files changed, 733 insertions(+) create mode 100644 packages/ambientweather/client.ts create mode 100644 packages/ambientweather/endpoints/example.ts create mode 100644 packages/ambientweather/endpoints/index.ts create mode 100644 packages/ambientweather/endpoints/types.ts create mode 100644 packages/ambientweather/error-handlers.ts create mode 100644 packages/ambientweather/index.ts create mode 100644 packages/ambientweather/jest.config.cjs create mode 100644 packages/ambientweather/package.json create mode 100644 packages/ambientweather/schema.test.ts create mode 100644 packages/ambientweather/schema/database.ts create mode 100644 packages/ambientweather/schema/index.ts create mode 100644 packages/ambientweather/tsconfig.json create mode 100644 packages/ambientweather/tsup.config.ts create mode 100644 packages/ambientweather/webhooks/example.ts create mode 100644 packages/ambientweather/webhooks/index.ts create mode 100644 packages/ambientweather/webhooks/oauth-tenant-link.ts create mode 100644 packages/ambientweather/webhooks/tenant-matcher.ts create mode 100644 packages/ambientweather/webhooks/types.ts diff --git a/packages/ambientweather/client.ts b/packages/ambientweather/client.ts new file mode 100644 index 000000000..1a06c2b95 --- /dev/null +++ b/packages/ambientweather/client.ts @@ -0,0 +1,60 @@ +import type { ApiRequestOptions, OpenAPIConfig } from 'corsair/http'; +import { request } from 'corsair/http'; + +export class AmbientWeatherAPIError extends Error { + constructor( + message: string, + public readonly code?: string, + ) { + super(message); + this.name = 'AmbientWeatherAPIError'; + } +} + +// TODO: Update with your API base URL +const AMBIENTWEATHER_API_BASE = 'https://api.example.com'; + +export async function makeAmbientWeatherRequest( + endpoint: string, + apiKey: string, + options: { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + body?: Record; + query?: Record; + } = {}, +): Promise { + const { method = 'GET', body, query } = options; + + const config: OpenAPIConfig = { + BASE: AMBIENTWEATHER_API_BASE, + VERSION: '1.0.0', + WITH_CREDENTIALS: false, + CREDENTIALS: 'omit', + TOKEN: apiKey, + HEADERS: { + 'Content-Type': 'application/json', + // TODO: Add authentication headers + // 'Authorization': \`Bearer \${apiKey}\` + }, + }; + + const requestOptions: ApiRequestOptions = { + method, + url: endpoint, + body: + method === 'POST' || method === 'PUT' || method === 'PATCH' + ? body + : undefined, + mediaType: 'application/json; charset=utf-8', + query: method === 'GET' ? query : undefined, + }; + + try { + return await request(config, requestOptions); + } catch (error) { + if (error instanceof Error) { + throw new AmbientWeatherAPIError(error.message); + } + throw new AmbientWeatherAPIError('Unknown error'); + } +} diff --git a/packages/ambientweather/endpoints/example.ts b/packages/ambientweather/endpoints/example.ts new file mode 100644 index 000000000..5315173e9 --- /dev/null +++ b/packages/ambientweather/endpoints/example.ts @@ -0,0 +1,21 @@ +import { logEventFromContext } from 'corsair/core'; +import type { AmbientWeatherEndpoints } from '..'; +import { makeAmbientWeatherRequest } from '../client'; +import type { AmbientWeatherEndpointOutputs } from './types'; + +export const get: AmbientWeatherEndpoints['exampleGet'] = async ( + ctx, + input, +) => { + const response = await makeAmbientWeatherRequest< + AmbientWeatherEndpointOutputs['exampleGet'] + >(`example/${input.id}`, ctx.key, { method: 'GET' }); + + await logEventFromContext( + ctx, + 'ambientweather.example.get', + { ...input }, + 'completed', + ); + return response; +}; diff --git a/packages/ambientweather/endpoints/index.ts b/packages/ambientweather/endpoints/index.ts new file mode 100644 index 000000000..7dc74ef41 --- /dev/null +++ b/packages/ambientweather/endpoints/index.ts @@ -0,0 +1,7 @@ +import { get as exampleGet } from './example'; + +export const Example = { + get: exampleGet, +}; + +export * from './types'; diff --git a/packages/ambientweather/endpoints/types.ts b/packages/ambientweather/endpoints/types.ts new file mode 100644 index 000000000..4ca0fbfe2 --- /dev/null +++ b/packages/ambientweather/endpoints/types.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; + +const ExampleGetInputSchema = z.object({ + id: z.string(), +}); + +export type ExampleGetInput = z.infer; + +const ExampleGetResponseSchema = z.object({ + id: z.string(), +}); + +export type ExampleGetResponse = z.infer; + +export type AmbientWeatherEndpointInputs = { + exampleGet: ExampleGetInput; +}; + +export type AmbientWeatherEndpointOutputs = { + exampleGet: ExampleGetResponse; +}; + +export const AmbientWeatherEndpointInputSchemas = { + exampleGet: ExampleGetInputSchema, +} as const; + +export const AmbientWeatherEndpointOutputSchemas = { + exampleGet: ExampleGetResponseSchema, +} as const; diff --git a/packages/ambientweather/error-handlers.ts b/packages/ambientweather/error-handlers.ts new file mode 100644 index 000000000..5a4f4c19f --- /dev/null +++ b/packages/ambientweather/error-handlers.ts @@ -0,0 +1,31 @@ +import type { CorsairErrorHandler } from 'corsair/core'; +import { ApiError } from 'corsair/http'; + +export const errorHandlers = { + RATE_LIMIT_ERROR: { + match: (error: Error) => { + if (error instanceof ApiError && error.status === 429) return true; + const msg = error.message.toLowerCase(); + return msg.includes('rate_limited') || msg.includes('429'); + }, + handler: async (error: Error) => { + let retryAfterMs: number | undefined; + if (error instanceof ApiError && error.retryAfter !== undefined) { + retryAfterMs = error.retryAfter; + } + return { maxRetries: 5, headersRetryAfterMs: retryAfterMs }; + }, + }, + AUTH_ERROR: { + match: (error: Error) => { + if (error instanceof ApiError && error.status === 401) return true; + const msg = error.message.toLowerCase(); + return msg.includes('unauthorized') || msg.includes('invalid_auth'); + }, + handler: async () => ({ maxRetries: 0 }), + }, + DEFAULT: { + match: () => true, + handler: async () => ({ maxRetries: 0 }), + }, +} satisfies CorsairErrorHandler; diff --git a/packages/ambientweather/index.ts b/packages/ambientweather/index.ts new file mode 100644 index 000000000..5e5ba0dc4 --- /dev/null +++ b/packages/ambientweather/index.ts @@ -0,0 +1,223 @@ +import type { + AuthTypes, + BindEndpoints, + BindWebhooks, + CorsairEndpoint, + CorsairErrorHandler, + CorsairPlugin, + CorsairPluginContext, + CorsairWebhook, + KeyBuilderContext, + PickAuth, + PluginAuthConfig, + PluginPermissionsConfig, + RequiredPluginEndpointMeta, + RequiredPluginEndpointSchemas, + RequiredPluginWebhookSchemas, +} from 'corsair/core'; +import { Example } from './endpoints'; +import type { + AmbientWeatherEndpointInputs, + AmbientWeatherEndpointOutputs, +} from './endpoints/types'; +import { + AmbientWeatherEndpointInputSchemas, + AmbientWeatherEndpointOutputSchemas, +} from './endpoints/types'; +import { errorHandlers } from './error-handlers'; +import { AmbientWeatherSchema } from './schema'; +import { ExampleWebhooks } from './webhooks'; +import { resolveAmbientWeatherOAuthWebhookTenantLink } from './webhooks/oauth-tenant-link'; +import { matchAmbientWeatherTenantWebhook } from './webhooks/tenant-matcher'; +import type { + AmbientWeatherWebhookOutputs, + ExampleEvent, +} from './webhooks/types'; +import { ExampleEventSchema } from './webhooks/types'; + +export type AmbientWeatherPluginOptions = { + authType?: PickAuth<'api_key' | 'oauth_2'>; + key?: string; + webhookSecret?: string; + hooks?: InternalAmbientWeatherPlugin['hooks']; + webhookHooks?: InternalAmbientWeatherPlugin['webhookHooks']; + errorHandlers?: CorsairErrorHandler; + permissions?: PluginPermissionsConfig; +}; + +export type AmbientWeatherContext = CorsairPluginContext< + typeof AmbientWeatherSchema, + AmbientWeatherPluginOptions +>; + +export type AmbientWeatherKeyBuilderContext = + KeyBuilderContext; + +export type AmbientWeatherBoundEndpoints = BindEndpoints< + typeof ambientWeatherEndpointsNested +>; + +type AmbientWeatherEndpoint = + CorsairEndpoint< + AmbientWeatherContext, + AmbientWeatherEndpointInputs[K], + AmbientWeatherEndpointOutputs[K] + >; + +export type AmbientWeatherEndpoints = { + exampleGet: AmbientWeatherEndpoint<'exampleGet'>; +}; + +type AmbientWeatherWebhook< + K extends keyof AmbientWeatherWebhookOutputs, + TEvent, +> = CorsairWebhook< + AmbientWeatherContext, + TEvent, + AmbientWeatherWebhookOutputs[K] +>; + +export type AmbientWeatherWebhooks = { + example: AmbientWeatherWebhook<'example', ExampleEvent>; +}; + +export type AmbientWeatherBoundWebhooks = BindWebhooks; + +const ambientWeatherEndpointsNested = { + example: { + get: Example.get, + }, +} as const; + +const ambientWeatherWebhooksNested = { + example: { + example: ExampleWebhooks.example, + }, +} as const; + +export const ambientWeatherEndpointSchemas = { + 'example.get': { + input: AmbientWeatherEndpointInputSchemas.exampleGet, + output: AmbientWeatherEndpointOutputSchemas.exampleGet, + }, +} as const satisfies RequiredPluginEndpointSchemas< + typeof ambientWeatherEndpointsNested +>; + +const ambientWeatherWebhookSchemas = { + 'example.example': { + description: 'An example webhook event', + payload: ExampleEventSchema, + response: ExampleEventSchema, + }, +} as const satisfies RequiredPluginWebhookSchemas< + typeof ambientWeatherWebhooksNested +>; + +const defaultAuthType: AuthTypes = 'api_key' as const; + +const ambientWeatherEndpointMeta = { + 'example.get': { + riskLevel: 'read', + description: 'Get an example resource by ID', + }, +} as const satisfies RequiredPluginEndpointMeta< + typeof ambientWeatherEndpointsNested +>; + +export const ambientWeatherAuthConfig = { + api_key: { + account: ['tenant_external_id'] as const, + }, + oauth_2: { + account: ['tenant_external_id'] as const, + }, +} as const satisfies PluginAuthConfig; + +export type BaseAmbientWeatherPlugin = + CorsairPlugin< + 'ambientweather', + typeof AmbientWeatherSchema, + typeof ambientWeatherEndpointsNested, + typeof ambientWeatherWebhooksNested, + T, + typeof defaultAuthType + >; + +export type InternalAmbientWeatherPlugin = + BaseAmbientWeatherPlugin; + +export type ExternalAmbientWeatherPlugin< + T extends AmbientWeatherPluginOptions, +> = BaseAmbientWeatherPlugin; + +export function ambientweather( + incomingOptions: AmbientWeatherPluginOptions & + T = {} as AmbientWeatherPluginOptions & T, +): ExternalAmbientWeatherPlugin { + const options = { + ...incomingOptions, + authType: incomingOptions.authType ?? defaultAuthType, + }; + return { + id: 'ambientweather', + authConfig: ambientWeatherAuthConfig, + schema: AmbientWeatherSchema, + options: options, + hooks: options.hooks, + webhookHooks: options.webhookHooks, + endpoints: ambientWeatherEndpointsNested, + webhooks: ambientWeatherWebhooksNested, + endpointMeta: ambientWeatherEndpointMeta, + endpointSchemas: ambientWeatherEndpointSchemas, + webhookSchemas: ambientWeatherWebhookSchemas, + pluginWebhookMatcher: (request) => { + const headers = request.headers; + // TODO: Update to match your webhook signature headers + return 'x-ambientweather-signature' in headers; + }, + pluginTenantWebhookMatcher: matchAmbientWeatherTenantWebhook, + oauthWebhookTenantLinkResolver: resolveAmbientWeatherOAuthWebhookTenantLink, + errorHandlers: { + ...errorHandlers, + ...options.errorHandlers, + }, + keyBuilder: async (ctx: AmbientWeatherKeyBuilderContext, source) => { + if (source === 'webhook' && options.webhookSecret) { + return options.webhookSecret; + } + + if (source === 'webhook') { + const res = await ctx.keys.get_webhook_signature(); + return res ?? ''; + } + + if (source === 'endpoint' && options.key) { + return options.key; + } + + if (source === 'endpoint' && ctx.authType === 'api_key') { + const res = await ctx.keys.get_api_key(); + return res ?? ''; + } + + if (source === 'endpoint' && ctx.authType === 'oauth_2') { + const res = await ctx.keys.get_access_token(); + return res ?? ''; + } + + return ''; + }, + } satisfies InternalAmbientWeatherPlugin; +} + +export type { + AmbientWeatherEndpointInputs, + AmbientWeatherEndpointOutputs, + ExampleGetInput, + ExampleGetResponse, +} from './endpoints/types'; +export type { + AmbientWeatherWebhookOutputs, + ExampleEvent, +} from './webhooks/types'; diff --git a/packages/ambientweather/jest.config.cjs b/packages/ambientweather/jest.config.cjs new file mode 100644 index 000000000..8c6218f64 --- /dev/null +++ b/packages/ambientweather/jest.config.cjs @@ -0,0 +1,55 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: [''], + testMatch: [ + '**/*.test.ts', + '**/tests/**/*.test.ts', + '**/plugins/**/*.test.ts', + '**/setup/**/*.test.ts', + ], + collectCoverageFrom: [ + '**/*.ts', + '!**/*.d.ts', + '!**/node_modules/**', + '!**/dist/**', + '!jest.config.ts', + '!tests/**', + ], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], + transform: { + '^.+\\.yaml$': '/../corsair/jest-yaml-transform.cjs', + '^.+\\.ts$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + verbatimModuleSyntax: false, + module: 'ESNext', + moduleResolution: 'Bundler', + }, + }, + ], + '.*\\.js$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + }, + }, + ], + }, + moduleNameMapper: { + '^corsair/core$': '/../corsair/core.ts', + '^corsair/http$': '/../corsair/http.ts', + '^(\\.\\.?/.*)\\.js$': '$1', + }, + transformIgnorePatterns: ['node_modules/(?!.*uuid.*)'], + extensionsToTreatAsEsm: ['.ts'], + testTimeout: 30000, + verbose: true, +}; diff --git a/packages/ambientweather/package.json b/packages/ambientweather/package.json new file mode 100644 index 000000000..df285a397 --- /dev/null +++ b/packages/ambientweather/package.json @@ -0,0 +1,44 @@ +{ + "name": "@corsair-dev/ambientweather", + "version": "0.1.0", + "description": "AmbientWeather plugin for Corsair", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "dev-source": "./index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "rm -rf dist && tsc --build --force && tsup", + "typecheck": "tsc --noEmit", + "test": "jest" + }, + "peerDependencies": { + "corsair": ">=0.1.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "corsair": "workspace:*", + "jest": "^29.7.0", + "ts-jest": "^29.4.9", + "tsup": "^8.0.1", + "typescript": "catalog:", + "zod": "^4.1.13" + }, + "keywords": [ + "corsair", + "ambientweather", + "plugin" + ], + "author": "", + "license": "Apache-2.0", + "files": [ + "dist" + ] +} diff --git a/packages/ambientweather/schema.test.ts b/packages/ambientweather/schema.test.ts new file mode 100644 index 000000000..47f834bf4 --- /dev/null +++ b/packages/ambientweather/schema.test.ts @@ -0,0 +1,22 @@ +import { AmbientWeatherSchema } from './schema'; + +describe('AmbientWeather schema', () => { + it('declares a semver version', () => { + expect(AmbientWeatherSchema.version).toBeDefined(); + expect(AmbientWeatherSchema.version).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it('declares an entities map', () => { + expect(typeof AmbientWeatherSchema.entities).toBe('object'); + expect(AmbientWeatherSchema.entities).not.toBeNull(); + expect(Array.isArray(Object.keys(AmbientWeatherSchema.entities))).toBe( + true, + ); + for (const entity of Object.values(AmbientWeatherSchema.entities)) { + expect(entity).toBeDefined(); + } + }); +}); + +// Per .github/PLUGIN_PR_RULES.md (R2), every implemented endpoint +// needs a corresponding test. diff --git a/packages/ambientweather/schema/database.ts b/packages/ambientweather/schema/database.ts new file mode 100644 index 000000000..bf98a358e --- /dev/null +++ b/packages/ambientweather/schema/database.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +// TODO: Define your database entities here +// export const AmbientWeatherExample = z.object({ +// id: z.string(), +// name: z.string(), +// created_at: z.coerce.date().nullable().optional(), +// }); +// export type AmbientWeatherExample = z.infer; diff --git a/packages/ambientweather/schema/index.ts b/packages/ambientweather/schema/index.ts new file mode 100644 index 000000000..83a0e2cd7 --- /dev/null +++ b/packages/ambientweather/schema/index.ts @@ -0,0 +1,4 @@ +export const AmbientWeatherSchema = { + version: '1.0.0', + entities: {}, +} as const; diff --git a/packages/ambientweather/tsconfig.json b/packages/ambientweather/tsconfig.json new file mode 100644 index 000000000..15e507a13 --- /dev/null +++ b/packages/ambientweather/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["esnext"], + "types": ["node", "jest"], + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "./dist", + "rootDir": "./", + "composite": true, + "incremental": true, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "skipLibCheck": true + }, + "include": ["./**/*"], + "exclude": ["dist", "node_modules"], + "references": [] +} diff --git a/packages/ambientweather/tsup.config.ts b/packages/ambientweather/tsup.config.ts new file mode 100644 index 000000000..3ec221e23 --- /dev/null +++ b/packages/ambientweather/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + clean: false, + dts: false, + format: ['esm'], + target: 'esnext', + platform: 'node', + bundle: true, + splitting: true, + minify: true, + outDir: 'dist', + external: ['corsair', 'zod'], + entry: ['index.ts'], +}); diff --git a/packages/ambientweather/webhooks/example.ts b/packages/ambientweather/webhooks/example.ts new file mode 100644 index 000000000..20bd38279 --- /dev/null +++ b/packages/ambientweather/webhooks/example.ts @@ -0,0 +1,35 @@ +import { logEventFromContext } from 'corsair/core'; +import type { AmbientWeatherWebhooks } from '..'; +import { + createAmbientWeatherMatch, + verifyAmbientWeatherWebhookSignature, +} from './types'; + +export const example: AmbientWeatherWebhooks['example'] = { + match: createAmbientWeatherMatch('example'), + + handler: async (ctx, request) => { + const verification = verifyAmbientWeatherWebhookSignature(request, ctx.key); + if (!verification.valid) { + return { + success: false, + statusCode: 401, + error: verification.error || 'Signature verification failed', + }; + } + + const event = request.payload; + if (event.type !== 'example') { + return { success: true, data: undefined }; + } + + await logEventFromContext( + ctx, + 'ambientweather.webhook.example', + { ...event }, + 'completed', + ); + + return { success: true, data: event }; + }, +}; diff --git a/packages/ambientweather/webhooks/index.ts b/packages/ambientweather/webhooks/index.ts new file mode 100644 index 000000000..a12134e8a --- /dev/null +++ b/packages/ambientweather/webhooks/index.ts @@ -0,0 +1,9 @@ +import { example } from './example'; + +export const ExampleWebhooks = { + example: example, +}; + +export * from './oauth-tenant-link'; +export * from './tenant-matcher'; +export * from './types'; diff --git a/packages/ambientweather/webhooks/oauth-tenant-link.ts b/packages/ambientweather/webhooks/oauth-tenant-link.ts new file mode 100644 index 000000000..7035fc23e --- /dev/null +++ b/packages/ambientweather/webhooks/oauth-tenant-link.ts @@ -0,0 +1,31 @@ +import type { TokenResponse, WebhookTenantMatch } from 'corsair/core'; +import { asRecord, toExternalId } from 'corsair/core'; + +// TODO: Rename linkType 'tenant_external_id' to match pluginTenantWebhookMatcher. +// Called after OAuth to store the routing id on corsair_accounts.config. +export async function resolveAmbientWeatherOAuthWebhookTenantLink( + tokens: TokenResponse, +): Promise { + // TODO: Read from token response when the provider includes a stable id. + // const externalId = toExternalId(asRecord(tokens.team)?.id); + const externalId = toExternalId(tokens.tenant_external_id); + if (externalId) { + return { linkType: 'tenant_external_id', externalId }; + } + + const accessToken = tokens.access_token; + if (!accessToken) return null; + + // TODO: Fetch from provider API when the token response omits the id. + // const response = await fetch('https://api.example.com/me', { + // headers: { Authorization: `Bearer ${accessToken}` }, + // }); + // if (!response.ok) return null; + // const payload = (await response.json()) as { id?: string }; + // const fetchedId = toExternalId(payload.id); + // return fetchedId + // ? { linkType: 'tenant_external_id', externalId: fetchedId } + // : null; + + return null; +} diff --git a/packages/ambientweather/webhooks/tenant-matcher.ts b/packages/ambientweather/webhooks/tenant-matcher.ts new file mode 100644 index 000000000..9f4cf1eb5 --- /dev/null +++ b/packages/ambientweather/webhooks/tenant-matcher.ts @@ -0,0 +1,25 @@ +import type { RawWebhookRequest, WebhookTenantMatch } from 'corsair/core'; +import { asRecord, firstString, readBodyRecord } from 'corsair/core'; + +// TODO: Rename linkType 'tenant_external_id' to match the provider field +// (e.g. team_id, installation_id, organization_id). Must match authConfig.account +// and oauthWebhookTenantLinkResolver. +// Return null for URL verification / handshake payloads that have no tenant id. +export function matchAmbientWeatherTenantWebhook( + request: RawWebhookRequest, +): WebhookTenantMatch | null { + const body = readBodyRecord(request); + if (!body) return null; + + // TODO: Extract the stable external id from the webhook payload. + // Example: + // const externalId = firstString([body.tenant_external_id, asRecord(body.data)?.id]); + const externalId = firstString([ + body.tenant_external_id, + asRecord(body.data)?.tenant_external_id, + ]); + + if (!externalId) return null; + + return { linkType: 'tenant_external_id', externalId }; +} diff --git a/packages/ambientweather/webhooks/types.ts b/packages/ambientweather/webhooks/types.ts new file mode 100644 index 000000000..9e7928073 --- /dev/null +++ b/packages/ambientweather/webhooks/types.ts @@ -0,0 +1,66 @@ +import type { + CorsairWebhookMatcher, + RawWebhookRequest, + WebhookRequest, +} from 'corsair/core'; +import { z } from 'zod'; + +export const AmbientWeatherWebhookPayloadSchema = z.object({ + type: z.string(), + created_at: z.string(), + data: z.record(z.string(), z.unknown()), +}); + +export type AmbientWeatherWebhookPayload = z.infer< + typeof AmbientWeatherWebhookPayloadSchema +>; + +export const ExampleEventSchema = AmbientWeatherWebhookPayloadSchema.extend({ + type: z.literal('example'), + data: z + .object({ + id: z.string(), + }) + .loose(), +}); + +export type ExampleEvent = z.infer; + +export type AmbientWeatherWebhookOutputs = { + example: ExampleEvent; +}; + +function parseBody(body: unknown): Record | null { + if (typeof body === 'string') { + try { + const parsed = JSON.parse(body); + return parsed !== null && + typeof parsed === 'object' && + !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } + } + return body !== null && typeof body === 'object' && !Array.isArray(body) + ? (body as Record) + : null; +} + +export function createAmbientWeatherMatch( + eventType: string, +): CorsairWebhookMatcher { + return (request: RawWebhookRequest) => { + const parsedBody = parseBody(request.body); + return parsedBody !== null && parsedBody.type === eventType; + }; +} + +export function verifyAmbientWeatherWebhookSignature( + request: WebhookRequest, + secret: string, +): { valid: boolean; error?: string } { + // TODO: Implement webhook signature verification + return { valid: true }; +} diff --git a/packages/corsair/core/constants.ts b/packages/corsair/core/constants.ts index e33ec2ca8..8fa6bc159 100644 --- a/packages/corsair/core/constants.ts +++ b/packages/corsair/core/constants.ts @@ -23,6 +23,7 @@ export const BaseProviders = [ 'airtable', 'algolia', 'alttextai', + 'ambientweather', 'amplitude', 'apilabz', 'asana', @@ -127,6 +128,7 @@ export const ProviderDisplayNames = { airtable: 'Airtable', algolia: 'Algolia', alttextai: 'AltText.ai', + ambientweather: 'AmbientWeather', amplitude: 'Amplitude', apilabz: 'API Labz', asana: 'Asana', @@ -238,6 +240,7 @@ export type AllProviders = | 'airtable' | 'algolia' | 'alttextai' + | 'ambientweather' | 'amplitude' | 'apilabz' | 'asana' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 91d5ded73..166c996e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -548,6 +548,30 @@ importers: specifier: 4.4.3 version: 4.4.3 + packages/ambientweather: + devDependencies: + '@types/jest': + specifier: ^29.5.14 + version: 29.5.14 + corsair: + specifier: workspace:* + version: link:../corsair + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) + ts-jest: + specifier: ^29.4.9 + version: 29.4.9(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.27.0)(jest-util@30.4.1)(jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(typescript@5.9.3) + tsup: + specifier: ^8.0.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: 'catalog:' + version: 5.9.3 + zod: + specifier: 4.4.3 + version: 4.4.3 + packages/amplitude: devDependencies: '@types/jest': From a4a7b3d395e84d0e248d610dc9312e4dce38140a Mon Sep 17 00:00:00 2001 From: Meet Batra Date: Mon, 10 Aug 2026 13:31:42 +0530 Subject: [PATCH 2/9] feat(plugin): implement Ambient Weather plugin with docs --- demo/mcp/corsair.ts | 1 - docs/plugins/ambientweather/api.mdx | 91 ++++++++ .../ambientweather/get-credentials.mdx | 40 ++++ docs/plugins/ambientweather/overview.mdx | 80 +++++++ packages/ambientweather/api.test.ts | 219 ++++++++++++++++++ packages/ambientweather/client.ts | 133 +++++++++-- packages/ambientweather/endpoints/devices.ts | 49 ++++ packages/ambientweather/endpoints/example.ts | 21 -- packages/ambientweather/endpoints/index.ts | 7 +- packages/ambientweather/endpoints/types.ts | 98 +++++++- packages/ambientweather/error-handlers.ts | 57 +++-- packages/ambientweather/index.ts | 192 +++++++-------- packages/ambientweather/schema/database.ts | 10 +- packages/ambientweather/tsconfig.json | 4 +- packages/ambientweather/webhooks/example.ts | 35 --- packages/ambientweather/webhooks/index.ts | 9 - .../webhooks/oauth-tenant-link.ts | 31 --- .../ambientweather/webhooks/tenant-matcher.ts | 25 -- packages/ambientweather/webhooks/types.ts | 66 ------ www/src/app/oss/u/[username]/page.tsx | 2 +- www/src/components/landing/menu/site-menu.tsx | 2 +- 21 files changed, 803 insertions(+), 369 deletions(-) create mode 100644 docs/plugins/ambientweather/api.mdx create mode 100644 docs/plugins/ambientweather/get-credentials.mdx create mode 100644 docs/plugins/ambientweather/overview.mdx create mode 100644 packages/ambientweather/api.test.ts create mode 100644 packages/ambientweather/endpoints/devices.ts delete mode 100644 packages/ambientweather/endpoints/example.ts delete mode 100644 packages/ambientweather/webhooks/example.ts delete mode 100644 packages/ambientweather/webhooks/index.ts delete mode 100644 packages/ambientweather/webhooks/oauth-tenant-link.ts delete mode 100644 packages/ambientweather/webhooks/tenant-matcher.ts delete mode 100644 packages/ambientweather/webhooks/types.ts diff --git a/demo/mcp/corsair.ts b/demo/mcp/corsair.ts index 25a1e2cfb..066808855 100644 --- a/demo/mcp/corsair.ts +++ b/demo/mcp/corsair.ts @@ -1,6 +1,5 @@ import 'dotenv/config'; import { github } from '@corsair-dev/github'; -import { gmail } from '@corsair-dev/gmail'; import { linear } from '@corsair-dev/linear'; // import { googlecalendar } from '@corsair-dev/googlecalendar' import { slack } from '@corsair-dev/slack'; diff --git a/docs/plugins/ambientweather/api.mdx b/docs/plugins/ambientweather/api.mdx new file mode 100644 index 000000000..91d67d20a --- /dev/null +++ b/docs/plugins/ambientweather/api.mdx @@ -0,0 +1,91 @@ +--- +title: API Reference +description: "Ambient Weather API reference for Corsair" +--- + +Every `ambientweather.api.*` operation is listed below with the request shape and return type. + + +**New to Corsair?** See [API access](/concepts/api), [authentication](/concepts/auth), and [error handling](/concepts/error-handling). + + +## devices.list + +`devices.list` + +**Method:** `GET /v1/devices` + +**Auth:** `apiKey` + `applicationKey` as query params, injected automatically + +**Returns:** `DeviceListItem[]` + +Each `DeviceListItem` contains: + +- `macAddress` (`string`) +- `info.name` (`string`) +- `info.location` (`string`) +- `lastData` (`WeatherData`) + +**Required params:** None + +**Example** + +```ts +await corsair.ambientweather.api.devices.list(); +``` + +## devices.getData + +`devices.getData` + +**Method:** `GET /v1/devices/:macAddress` + +**Auth:** `apiKey` + `applicationKey` as query params, injected automatically + +**Required params** + +| Name | Type | Description | +|------|------|-------------| +| `macAddress` | `string` | MAC address of the station | + +**Optional params** + +| Name | Type | Description | +|------|------|-------------| +| `limit` | `number` | 1-288, default `1` | +| `endDate` | `number` | Unix timestamp in milliseconds to end the data range | + +**Returns:** `WeatherData[]` + +Data is returned in chronological order and typically comes back in 5 or 30 minute increments. + +**Rate limit note:** Ambient Weather allows at most 1 request per second. If that limit is exceeded, Corsair throws `AmbientWeatherRateLimitError`. + +**Example** + +```ts +await corsair.ambientweather.api.devices.getData({ + macAddress: 'AA:BB:CC:DD:EE:FF', + limit: 24, +}); +``` + +## WeatherData shape + +The most important fields in `WeatherData` are: + +- `dateutc` +- `tempf` +- `humidity` +- `windspeedmph` +- `windgustmph` +- `winddir` +- `uv` +- `solarradiation` +- `dailyrainin` +- `baromrelin` +- `feelsLike` +- `dewPoint` +- `date` (`ISO string`) + +Full spec: https://github.com/ambient-weather/api-docs/wiki/Device-Data-Specs diff --git a/docs/plugins/ambientweather/get-credentials.mdx b/docs/plugins/ambientweather/get-credentials.mdx new file mode 100644 index 000000000..8669cc90a --- /dev/null +++ b/docs/plugins/ambientweather/get-credentials.mdx @@ -0,0 +1,40 @@ +--- +title: Get Credentials +description: "How to get Ambient Weather API credentials" +--- + +## Get your Ambient Weather keys + + + + +Go to [ambientweather.net](https://ambientweather.net) and sign in to your account. + + + +From your account menu, open **Account Settings**. + + + +Scroll to the **API Keys** section. + + + +Create an API key. This is your `apiKey`. + + + +Create an Application key. This is your `applicationKey`, and you need one per app you build. + + + +Use these credential field names with `pnpm corsair setup`: + +```bash +pnpm corsair setup --plugin=ambientweather api_key=your-api-key application_key=your-application-key +``` + + + + +Both fields are required. Neither expires or needs refresh. diff --git a/docs/plugins/ambientweather/overview.mdx b/docs/plugins/ambientweather/overview.mdx new file mode 100644 index 000000000..e67073359 --- /dev/null +++ b/docs/plugins/ambientweather/overview.mdx @@ -0,0 +1,80 @@ +--- +title: Overview +description: "Ambient Weather plugin for Corsair" +--- + +Use **Ambient Weather** through Corsair: one client, typed API calls, and no webhook surface. + +**What you get:** + +- 2 typed API operations +- 0 webhook event types +- No OAuth + +## Setup + + + + +```bash +pnpm install @corsair-dev/ambientweather +``` + + + +```ts corsair.ts +import { createCorsair } from 'corsair'; +import { ambientweather } from '@corsair-dev/ambientweather'; + +export const corsair = createCorsair({ + // ... other config options, + plugins: [ambientweather()], +}); +``` + + + +```bash +pnpm corsair setup --plugin=ambientweather api_key= application_key= +``` + + + + +## Authentication + + + + +Ambient Weather uses API key auth, not OAuth. Corsair sends both `apiKey` and `applicationKey` as query parameters on every request. There is no redirect flow and no token refresh flow. + +```ts corsair.ts +ambientweather() +``` + + + + +## Example API calls + +**List stations** + +```ts +const stations = await corsair.ambientweather.api.devices.list(); +``` + +**Fetch station history** + +```ts +const history = await corsair.ambientweather.api.devices.getData({ + macAddress: 'AA:BB:CC:DD:EE:FF', + limit: 24, +}); +``` + +## Reference + +| Topic | Link | +|-------|------| +| API | [API Reference](/plugins/ambientweather/api) | +| Credentials | [Get credentials](/plugins/ambientweather/get-credentials) | diff --git a/packages/ambientweather/api.test.ts b/packages/ambientweather/api.test.ts new file mode 100644 index 000000000..f4ba81522 --- /dev/null +++ b/packages/ambientweather/api.test.ts @@ -0,0 +1,219 @@ +import type { ApiRequestOptions, ApiResult } from 'corsair/http'; +import { ApiError, request } from 'corsair/http'; +import { + makeAmbientWeatherRequest, + packAmbientWeatherCredentials, + parseAmbientWeatherKey, +} from './client'; +import { getData, list } from './endpoints/devices'; +import { + AmbientWeatherDeviceDataResponseSchema, + AmbientWeatherDeviceListResponseSchema, + AmbientWeatherEndpointOutputSchemas, +} from './endpoints/types'; +import { ambientweather, ambientweatherAuthConfig } from './index'; + +jest.mock('corsair/http', () => { + const actual = jest.requireActual( + 'corsair/http', + ) as typeof import('corsair/http'); + return { + ...actual, + request: jest.fn(), + }; +}); + +const mockedRequest = request as jest.MockedFunction; + +describe('ambientweather client', () => { + beforeEach(() => { + mockedRequest.mockReset(); + }); + + it('packs and parses credentials for the key builder', () => { + const credentials = { + apiKey: 'user-api-key', + applicationKey: 'developer-app-key', + }; + + const packed = packAmbientWeatherCredentials(credentials); + expect(parseAmbientWeatherKey(packed)).toEqual(credentials); + expect(parseAmbientWeatherKey('not-json')).toBeNull(); + }); + + it('adds both auth query params on every request', async () => { + const sampleResponse = [ + { + macAddress: '00:11:22:33:44:55', + info: { + name: 'Backyard Station', + location: 'Patio', + }, + lastData: { + dateutc: 1720000000000, + date: '2024-07-03 12:00:00', + tz: 'America/Los_Angeles', + tempf: 72.5, + humidity: 44, + }, + }, + ]; + + mockedRequest.mockResolvedValueOnce(sampleResponse); + + const response = await makeAmbientWeatherRequest( + '/v1/devices', + 'user-api-key', + 'developer-app-key', + { + query: { + limit: 1, + }, + }, + ); + + AmbientWeatherDeviceListResponseSchema.parse(response); + + expect(mockedRequest).toHaveBeenCalledTimes(1); + const firstCall = mockedRequest.mock.calls[0]; + expect(firstCall).toBeDefined(); + const [config, requestOptions, requestConfig] = firstCall!; + + expect(config.BASE).toBe('https://api.ambientweather.net'); + expect(requestOptions.url).toBe('/v1/devices'); + expect(requestOptions.query).toEqual({ + limit: 1, + apiKey: 'user-api-key', + applicationKey: 'developer-app-key', + }); + expect(requestConfig?.rateLimitConfig).toMatchObject({ maxRetries: 0 }); + }); + + it('wraps 429 responses in AmbientWeatherRateLimitError', async () => { + const apiError = new ApiError( + { method: 'GET', url: '/v1/devices' } as ApiRequestOptions, + { + url: 'https://api.ambientweather.net/v1/devices', + ok: false, + status: 429, + statusText: 'Too Many Requests', + body: { error: 'rate limited' }, + } as ApiResult, + 'Too Many Requests', + { retryAfter: 1000 }, + ); + + mockedRequest.mockRejectedValueOnce(apiError); + + await expect( + makeAmbientWeatherRequest( + '/v1/devices', + 'user-api-key', + 'developer-app-key', + ), + ).rejects.toMatchObject({ + name: 'AmbientWeatherRateLimitError', + code: 429, + status: 429, + }); + expect(mockedRequest).toHaveBeenCalledTimes(1); + }); +}); + +describe('ambientweather endpoints', () => { + beforeEach(() => { + mockedRequest.mockReset(); + }); + + it('lists devices using the packed account credentials', async () => { + const response = [ + { + macAddress: '00:11:22:33:44:55', + info: { + name: 'Backyard Station', + location: 'Patio', + }, + lastData: { + dateutc: 1720000000000, + date: '2024-07-03 12:00:00', + tz: 'America/Los_Angeles', + tempf: 72.5, + humidity: 44, + }, + }, + ]; + + mockedRequest.mockResolvedValueOnce(response); + + const ctx = { + key: packAmbientWeatherCredentials({ + apiKey: 'user-api-key', + applicationKey: 'developer-app-key', + }), + } as Parameters[0]; + + const parsed = await list(ctx, {}); + + AmbientWeatherEndpointOutputSchemas.devicesList.parse(parsed); + expect(mockedRequest).toHaveBeenCalledTimes(1); + expect(mockedRequest.mock.calls[0]?.[1]).toMatchObject({ + url: '/v1/devices', + query: { + apiKey: 'user-api-key', + applicationKey: 'developer-app-key', + }, + }); + }); + + it('fetches device history with path params and query args', async () => { + const response = [ + { + dateutc: 1720000000000, + date: '2024-07-03 12:00:00', + tz: 'America/Los_Angeles', + tempf: 72.5, + humidity: 44, + }, + ]; + + mockedRequest.mockResolvedValueOnce(response); + + const ctx = { + key: packAmbientWeatherCredentials({ + apiKey: 'user-api-key', + applicationKey: 'developer-app-key', + }), + } as Parameters[0]; + + const parsed = await getData(ctx, { + macAddress: '00:11:22:33:44:55', + limit: 12, + endDate: 1720000000000, + }); + + AmbientWeatherDeviceDataResponseSchema.parse(parsed); + expect(mockedRequest).toHaveBeenCalledTimes(1); + expect(mockedRequest.mock.calls[0]?.[1]).toMatchObject({ + url: '/v1/devices/00%3A11%3A22%3A33%3A44%3A55', + query: { + apiKey: 'user-api-key', + applicationKey: 'developer-app-key', + limit: 12, + endDate: 1720000000000, + }, + }); + }); +}); + +describe('ambientweather factory', () => { + it('registers the two read endpoints with the expected auth config', () => { + const plugin = ambientweather(); + + expect(plugin.id).toBe('ambientweather'); + expect(plugin.authConfig).toEqual(ambientweatherAuthConfig); + expect(plugin.endpoints).toBeDefined(); + expect(plugin.endpoints!.devices.list).toEqual(expect.any(Function)); + expect(plugin.endpoints!.devices.getData).toEqual(expect.any(Function)); + expect(Object.keys(plugin.webhooks ?? {})).toHaveLength(0); + }); +}); diff --git a/packages/ambientweather/client.ts b/packages/ambientweather/client.ts index 1a06c2b95..221594ede 100644 --- a/packages/ambientweather/client.ts +++ b/packages/ambientweather/client.ts @@ -1,60 +1,145 @@ -import type { ApiRequestOptions, OpenAPIConfig } from 'corsair/http'; -import { request } from 'corsair/http'; +import type { + ApiRequestOptions, + OpenAPIConfig, + RateLimitConfig, +} from 'corsair/http'; +import { ApiError, request } from 'corsair/http'; +import { z } from 'zod'; + +export const AmbientWeatherCredentialsSchema = z.object({ + apiKey: z.string().min(1), + applicationKey: z.string().min(1), +}); + +export type AmbientWeatherCredentials = z.infer< + typeof AmbientWeatherCredentialsSchema +>; export class AmbientWeatherAPIError extends Error { + public readonly status?: number; + public readonly statusText?: string; + public readonly body?: unknown; + public readonly retryAfter?: number; + public readonly rateLimitReset?: number; + public readonly rateLimitRemaining?: number; + public readonly rateLimitLimit?: number; + constructor( message: string, - public readonly code?: string, + public readonly code?: number, + options?: { cause?: Error }, ) { - super(message); + super(message, options); this.name = 'AmbientWeatherAPIError'; + + if (options?.cause instanceof ApiError) { + this.status = options.cause.status; + this.statusText = options.cause.statusText; + this.body = options.cause.body; + this.retryAfter = options.cause.retryAfter; + this.rateLimitReset = options.cause.rateLimitReset; + this.rateLimitRemaining = options.cause.rateLimitRemaining; + this.rateLimitLimit = options.cause.rateLimitLimit; + } } } -// TODO: Update with your API base URL -const AMBIENTWEATHER_API_BASE = 'https://api.example.com'; +export class AmbientWeatherRateLimitError extends AmbientWeatherAPIError { + constructor(message: string, options?: { cause?: Error }) { + super(message, 429, options); + this.name = 'AmbientWeatherRateLimitError'; + } +} + +const AMBIENTWEATHER_API_BASE = 'https://api.ambientweather.net'; + +const AMBIENTWEATHER_RATE_LIMIT_CONFIG: RateLimitConfig = { + enabled: true, + maxRetries: 0, + initialRetryDelay: 1000, + backoffMultiplier: 2, + headerNames: { + retryAfter: 'Retry-After', + }, +}; + +export type AmbientWeatherQueryValue = string | number | boolean | undefined; + +export type AmbientWeatherRequestQuery = Record< + string, + AmbientWeatherQueryValue +>; + +export function packAmbientWeatherCredentials( + credentials: AmbientWeatherCredentials, +): string { + return JSON.stringify(credentials); +} + +export function parseAmbientWeatherKey( + key: string, +): AmbientWeatherCredentials | null { + try { + const parsed = JSON.parse(key) as unknown; + const credentials = AmbientWeatherCredentialsSchema.safeParse(parsed); + return credentials.success ? credentials.data : null; + } catch { + return null; + } +} export async function makeAmbientWeatherRequest( endpoint: string, apiKey: string, + applicationKey: string, options: { - method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; - body?: Record; - query?: Record; + query?: AmbientWeatherRequestQuery; } = {}, ): Promise { - const { method = 'GET', body, query } = options; - const config: OpenAPIConfig = { BASE: AMBIENTWEATHER_API_BASE, VERSION: '1.0.0', WITH_CREDENTIALS: false, CREDENTIALS: 'omit', - TOKEN: apiKey, + TOKEN: undefined, HEADERS: { + Accept: 'application/json', 'Content-Type': 'application/json', - // TODO: Add authentication headers - // 'Authorization': \`Bearer \${apiKey}\` }, }; const requestOptions: ApiRequestOptions = { - method, + method: 'GET', url: endpoint, - body: - method === 'POST' || method === 'PUT' || method === 'PATCH' - ? body - : undefined, - mediaType: 'application/json; charset=utf-8', - query: method === 'GET' ? query : undefined, + query: { + ...options.query, + apiKey, + applicationKey, + }, }; try { - return await request(config, requestOptions); + return await request(config, requestOptions, { + rateLimitConfig: AMBIENTWEATHER_RATE_LIMIT_CONFIG, + }); } catch (error) { + if (error instanceof ApiError) { + if (error.status === 429) { + throw new AmbientWeatherRateLimitError(error.message, { + cause: error, + }); + } + throw new AmbientWeatherAPIError(error.message, error.status, { + cause: error, + }); + } + if (error instanceof Error) { - throw new AmbientWeatherAPIError(error.message); + throw new AmbientWeatherAPIError(error.message, undefined, { + cause: error, + }); } - throw new AmbientWeatherAPIError('Unknown error'); + + throw new AmbientWeatherAPIError('Unknown Ambient Weather API error'); } } diff --git a/packages/ambientweather/endpoints/devices.ts b/packages/ambientweather/endpoints/devices.ts new file mode 100644 index 000000000..62ee34981 --- /dev/null +++ b/packages/ambientweather/endpoints/devices.ts @@ -0,0 +1,49 @@ +import { AuthMissingError } from 'corsair/core'; +import type { AmbientWeatherEndpoints } from '..'; +import { makeAmbientWeatherRequest, parseAmbientWeatherKey } from '../client'; +import type { AmbientWeatherEndpointOutputs } from './types'; + +function requireAmbientWeatherCredentials(key: string): { + apiKey: string; + applicationKey: string; +} { + const credentials = parseAmbientWeatherKey(key); + if (!credentials) { + throw new AuthMissingError('ambientweather', 'api_key'); + } + return credentials; +} + +export const list: AmbientWeatherEndpoints['devicesList'] = async ( + ctx, + input, +) => { + const { apiKey, applicationKey } = requireAmbientWeatherCredentials(ctx.key); + + return makeAmbientWeatherRequest< + AmbientWeatherEndpointOutputs['devicesList'] + >('/v1/devices', apiKey, applicationKey, { + query: input, + }); +}; + +export const getData: AmbientWeatherEndpoints['devicesGetData'] = async ( + ctx, + input, +) => { + const { apiKey, applicationKey } = requireAmbientWeatherCredentials(ctx.key); + + return makeAmbientWeatherRequest< + AmbientWeatherEndpointOutputs['devicesGetData'] + >( + `/v1/devices/${encodeURIComponent(input.macAddress)}`, + apiKey, + applicationKey, + { + query: { + limit: input.limit, + endDate: input.endDate, + }, + }, + ); +}; diff --git a/packages/ambientweather/endpoints/example.ts b/packages/ambientweather/endpoints/example.ts deleted file mode 100644 index 5315173e9..000000000 --- a/packages/ambientweather/endpoints/example.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { logEventFromContext } from 'corsair/core'; -import type { AmbientWeatherEndpoints } from '..'; -import { makeAmbientWeatherRequest } from '../client'; -import type { AmbientWeatherEndpointOutputs } from './types'; - -export const get: AmbientWeatherEndpoints['exampleGet'] = async ( - ctx, - input, -) => { - const response = await makeAmbientWeatherRequest< - AmbientWeatherEndpointOutputs['exampleGet'] - >(`example/${input.id}`, ctx.key, { method: 'GET' }); - - await logEventFromContext( - ctx, - 'ambientweather.example.get', - { ...input }, - 'completed', - ); - return response; -}; diff --git a/packages/ambientweather/endpoints/index.ts b/packages/ambientweather/endpoints/index.ts index 7dc74ef41..ab8d62442 100644 --- a/packages/ambientweather/endpoints/index.ts +++ b/packages/ambientweather/endpoints/index.ts @@ -1,7 +1,8 @@ -import { get as exampleGet } from './example'; +import { getData, list } from './devices'; -export const Example = { - get: exampleGet, +export const Devices = { + list, + getData, }; export * from './types'; diff --git a/packages/ambientweather/endpoints/types.ts b/packages/ambientweather/endpoints/types.ts index 4ca0fbfe2..2f6eb4b35 100644 --- a/packages/ambientweather/endpoints/types.ts +++ b/packages/ambientweather/endpoints/types.ts @@ -1,29 +1,105 @@ import { z } from 'zod'; -const ExampleGetInputSchema = z.object({ - id: z.string(), -}); +export const AmbientWeatherDeviceInfoSchema = z + .object({ + name: z.string(), + location: z.string().optional(), + }) + .passthrough(); + +export const AmbientWeatherDataPointSchema = z + .object({ + dateutc: z.number().int(), + tempf: z.number().optional(), + humidity: z.number().optional(), + windspeedmph: z.number().optional(), + windgustmph: z.number().optional(), + maxdailygust: z.number().optional(), + winddir: z.number().optional(), + uv: z.number().optional(), + solarradiation: z.number().optional(), + hourlyrainin: z.number().optional(), + eventrainin: z.number().optional(), + dailyrainin: z.number().optional(), + weeklyrainin: z.number().optional(), + monthlyrainin: z.number().optional(), + totalrainin: z.number().optional(), + baromrelin: z.number().optional(), + baromabsin: z.number().optional(), + tempinf: z.number().optional(), + humidityin: z.number().optional(), + feelsLike: z.number().optional(), + dewPoint: z.number().optional(), + lastRain: z.string().optional(), + tz: z.string().optional(), + date: z.string().optional(), + loc: z.string().optional(), + time: z.number().optional(), + }) + .passthrough(); + +export const AmbientWeatherDeviceListItemSchema = z + .object({ + macAddress: z.string(), + info: AmbientWeatherDeviceInfoSchema, + lastData: AmbientWeatherDataPointSchema, + }) + .passthrough(); + +export const AmbientWeatherDeviceListResponseSchema = z.array( + AmbientWeatherDeviceListItemSchema, +); + +export const AmbientWeatherDeviceDataResponseSchema = z.array( + AmbientWeatherDataPointSchema, +); -export type ExampleGetInput = z.infer; +export const AmbientWeatherDevicesListInputSchema = z.object({}); -const ExampleGetResponseSchema = z.object({ - id: z.string(), +export const AmbientWeatherDevicesGetDataInputSchema = z.object({ + macAddress: z.string().min(1), + limit: z.coerce.number().int().min(1).max(288).default(1), + endDate: z.coerce.number().int().optional(), }); -export type ExampleGetResponse = z.infer; +export type AmbientWeatherDeviceInfo = z.infer< + typeof AmbientWeatherDeviceInfoSchema +>; +export type AmbientWeatherDataPoint = z.infer< + typeof AmbientWeatherDataPointSchema +>; +export type AmbientWeatherDeviceListItem = z.infer< + typeof AmbientWeatherDeviceListItemSchema +>; +export type AmbientWeatherDeviceListResponse = z.infer< + typeof AmbientWeatherDeviceListResponseSchema +>; +export type AmbientWeatherDeviceDataResponse = z.infer< + typeof AmbientWeatherDeviceDataResponseSchema +>; +export type AmbientWeatherDevicesListInput = z.infer< + typeof AmbientWeatherDevicesListInputSchema +>; +export type AmbientWeatherDevicesGetDataInput = z.infer< + typeof AmbientWeatherDevicesGetDataInputSchema +>; export type AmbientWeatherEndpointInputs = { - exampleGet: ExampleGetInput; + devicesList: AmbientWeatherDevicesListInput; + devicesGetData: AmbientWeatherDevicesGetDataInput; }; export type AmbientWeatherEndpointOutputs = { - exampleGet: ExampleGetResponse; + devicesList: AmbientWeatherDeviceListResponse; + devicesGetData: AmbientWeatherDeviceDataResponse; }; export const AmbientWeatherEndpointInputSchemas = { - exampleGet: ExampleGetInputSchema, + devicesList: AmbientWeatherDevicesListInputSchema, + devicesGetData: AmbientWeatherDevicesGetDataInputSchema, } as const; export const AmbientWeatherEndpointOutputSchemas = { - exampleGet: ExampleGetResponseSchema, + devicesList: AmbientWeatherDeviceListResponseSchema, + devicesGetData: AmbientWeatherDeviceDataResponseSchema, } as const; diff --git a/packages/ambientweather/error-handlers.ts b/packages/ambientweather/error-handlers.ts index 5a4f4c19f..751a57542 100644 --- a/packages/ambientweather/error-handlers.ts +++ b/packages/ambientweather/error-handlers.ts @@ -1,29 +1,54 @@ import type { CorsairErrorHandler } from 'corsair/core'; -import { ApiError } from 'corsair/http'; +import type { AmbientWeatherAPIError } from './client'; + +function getStatus(error: Error): number | undefined { + return ( + (error as Partial).code ?? + (error as Partial).status + ); +} + +function getRetryAfter(error: Error): number | undefined { + return (error as Partial).retryAfter; +} export const errorHandlers = { RATE_LIMIT_ERROR: { - match: (error: Error) => { - if (error instanceof ApiError && error.status === 429) return true; - const msg = error.message.toLowerCase(); - return msg.includes('rate_limited') || msg.includes('429'); - }, - handler: async (error: Error) => { - let retryAfterMs: number | undefined; - if (error instanceof ApiError && error.retryAfter !== undefined) { - retryAfterMs = error.retryAfter; - } - return { maxRetries: 5, headersRetryAfterMs: retryAfterMs }; + match: (error) => { + const status = getStatus(error); + if (status === 429) return true; + const message = error.message.toLowerCase(); + return message.includes('rate limit') || message.includes('429'); }, + handler: async (error) => ({ + maxRetries: 0, + headersRetryAfterMs: getRetryAfter(error), + }), }, AUTH_ERROR: { - match: (error: Error) => { - if (error instanceof ApiError && error.status === 401) return true; - const msg = error.message.toLowerCase(); - return msg.includes('unauthorized') || msg.includes('invalid_auth'); + match: (error) => { + const status = getStatus(error); + if (status === 401 || status === 403) return true; + const message = error.message.toLowerCase(); + return ( + message.includes('unauthorized') || + message.includes('forbidden') || + message.includes('invalid') || + message.includes('authentication') + ); }, handler: async () => ({ maxRetries: 0 }), }, + SERVER_ERROR: { + match: (error) => { + const status = getStatus(error); + return status !== undefined && status >= 500; + }, + handler: async () => ({ + maxRetries: 2, + retryStrategy: 'exponential_backoff' as const, + }), + }, DEFAULT: { match: () => true, handler: async () => ({ maxRetries: 0 }), diff --git a/packages/ambientweather/index.ts b/packages/ambientweather/index.ts index 5e5ba0dc4..4fbe16159 100644 --- a/packages/ambientweather/index.ts +++ b/packages/ambientweather/index.ts @@ -1,21 +1,20 @@ import type { AuthTypes, BindEndpoints, - BindWebhooks, CorsairEndpoint, CorsairErrorHandler, CorsairPlugin, CorsairPluginContext, - CorsairWebhook, KeyBuilderContext, PickAuth, PluginAuthConfig, PluginPermissionsConfig, RequiredPluginEndpointMeta, RequiredPluginEndpointSchemas, - RequiredPluginWebhookSchemas, } from 'corsair/core'; -import { Example } from './endpoints'; +import { AuthMissingError } from 'corsair/core'; +import { packAmbientWeatherCredentials } from './client'; +import { Devices } from './endpoints'; import type { AmbientWeatherEndpointInputs, AmbientWeatherEndpointOutputs, @@ -26,35 +25,28 @@ import { } from './endpoints/types'; import { errorHandlers } from './error-handlers'; import { AmbientWeatherSchema } from './schema'; -import { ExampleWebhooks } from './webhooks'; -import { resolveAmbientWeatherOAuthWebhookTenantLink } from './webhooks/oauth-tenant-link'; -import { matchAmbientWeatherTenantWebhook } from './webhooks/tenant-matcher'; -import type { - AmbientWeatherWebhookOutputs, - ExampleEvent, -} from './webhooks/types'; -import { ExampleEventSchema } from './webhooks/types'; export type AmbientWeatherPluginOptions = { - authType?: PickAuth<'api_key' | 'oauth_2'>; - key?: string; - webhookSecret?: string; + authType?: PickAuth<'api_key'>; hooks?: InternalAmbientWeatherPlugin['hooks']; - webhookHooks?: InternalAmbientWeatherPlugin['webhookHooks']; errorHandlers?: CorsairErrorHandler; - permissions?: PluginPermissionsConfig; + permissions?: PluginPermissionsConfig; }; export type AmbientWeatherContext = CorsairPluginContext< typeof AmbientWeatherSchema, - AmbientWeatherPluginOptions + AmbientWeatherPluginOptions, + undefined, + typeof ambientweatherAuthConfig >; -export type AmbientWeatherKeyBuilderContext = - KeyBuilderContext; +export type AmbientWeatherKeyBuilderContext = KeyBuilderContext< + AmbientWeatherPluginOptions, + typeof ambientweatherAuthConfig +>; export type AmbientWeatherBoundEndpoints = BindEndpoints< - typeof ambientWeatherEndpointsNested + typeof ambientweatherEndpointsNested >; type AmbientWeatherEndpoint = @@ -65,72 +57,52 @@ type AmbientWeatherEndpoint = >; export type AmbientWeatherEndpoints = { - exampleGet: AmbientWeatherEndpoint<'exampleGet'>; + devicesList: AmbientWeatherEndpoint<'devicesList'>; + devicesGetData: AmbientWeatherEndpoint<'devicesGetData'>; }; -type AmbientWeatherWebhook< - K extends keyof AmbientWeatherWebhookOutputs, - TEvent, -> = CorsairWebhook< - AmbientWeatherContext, - TEvent, - AmbientWeatherWebhookOutputs[K] ->; - -export type AmbientWeatherWebhooks = { - example: AmbientWeatherWebhook<'example', ExampleEvent>; -}; - -export type AmbientWeatherBoundWebhooks = BindWebhooks; - -const ambientWeatherEndpointsNested = { - example: { - get: Example.get, +const ambientweatherEndpointsNested = { + devices: { + list: Devices.list, + getData: Devices.getData, }, } as const; -const ambientWeatherWebhooksNested = { - example: { - example: ExampleWebhooks.example, - }, -} as const; +const ambientweatherWebhooksNested = {} as const; -export const ambientWeatherEndpointSchemas = { - 'example.get': { - input: AmbientWeatherEndpointInputSchemas.exampleGet, - output: AmbientWeatherEndpointOutputSchemas.exampleGet, +export const ambientweatherEndpointSchemas = { + 'devices.list': { + input: AmbientWeatherEndpointInputSchemas.devicesList, + output: AmbientWeatherEndpointOutputSchemas.devicesList, + }, + 'devices.getData': { + input: AmbientWeatherEndpointInputSchemas.devicesGetData, + output: AmbientWeatherEndpointOutputSchemas.devicesGetData, }, } as const satisfies RequiredPluginEndpointSchemas< - typeof ambientWeatherEndpointsNested + typeof ambientweatherEndpointsNested >; -const ambientWeatherWebhookSchemas = { - 'example.example': { - description: 'An example webhook event', - payload: ExampleEventSchema, - response: ExampleEventSchema, +const ambientweatherEndpointMeta = { + 'devices.list': { + riskLevel: 'read', + description: + 'List all Ambient Weather devices for the connected account with their latest readings', }, -} as const satisfies RequiredPluginWebhookSchemas< - typeof ambientWeatherWebhooksNested ->; - -const defaultAuthType: AuthTypes = 'api_key' as const; - -const ambientWeatherEndpointMeta = { - 'example.get': { + 'devices.getData': { riskLevel: 'read', - description: 'Get an example resource by ID', + description: + 'Fetch historical weather data for a specific Ambient Weather device', }, } as const satisfies RequiredPluginEndpointMeta< - typeof ambientWeatherEndpointsNested + typeof ambientweatherEndpointsNested >; -export const ambientWeatherAuthConfig = { +const defaultAuthType: AuthTypes = 'api_key' as const; + +export const ambientweatherAuthConfig = { api_key: { - account: ['tenant_external_id'] as const, - }, - oauth_2: { - account: ['tenant_external_id'] as const, + account: ['applicationKey'] as const, }, } as const satisfies PluginAuthConfig; @@ -138,10 +110,11 @@ export type BaseAmbientWeatherPlugin = CorsairPlugin< 'ambientweather', typeof AmbientWeatherSchema, - typeof ambientWeatherEndpointsNested, - typeof ambientWeatherWebhooksNested, + typeof ambientweatherEndpointsNested, + typeof ambientweatherWebhooksNested, T, - typeof defaultAuthType + typeof defaultAuthType, + typeof ambientweatherAuthConfig >; export type InternalAmbientWeatherPlugin = @@ -159,65 +132,56 @@ export function ambientweather( ...incomingOptions, authType: incomingOptions.authType ?? defaultAuthType, }; + return { id: 'ambientweather', - authConfig: ambientWeatherAuthConfig, + authConfig: ambientweatherAuthConfig, schema: AmbientWeatherSchema, - options: options, + options, hooks: options.hooks, - webhookHooks: options.webhookHooks, - endpoints: ambientWeatherEndpointsNested, - webhooks: ambientWeatherWebhooksNested, - endpointMeta: ambientWeatherEndpointMeta, - endpointSchemas: ambientWeatherEndpointSchemas, - webhookSchemas: ambientWeatherWebhookSchemas, - pluginWebhookMatcher: (request) => { - const headers = request.headers; - // TODO: Update to match your webhook signature headers - return 'x-ambientweather-signature' in headers; - }, - pluginTenantWebhookMatcher: matchAmbientWeatherTenantWebhook, - oauthWebhookTenantLinkResolver: resolveAmbientWeatherOAuthWebhookTenantLink, + endpoints: ambientweatherEndpointsNested, + webhooks: ambientweatherWebhooksNested, + endpointMeta: ambientweatherEndpointMeta, + endpointSchemas: ambientweatherEndpointSchemas, + pluginWebhookMatcher: undefined, errorHandlers: { ...errorHandlers, ...options.errorHandlers, }, - keyBuilder: async (ctx: AmbientWeatherKeyBuilderContext, source) => { - if (source === 'webhook' && options.webhookSecret) { - return options.webhookSecret; + keyBuilder: async (ctx: AmbientWeatherKeyBuilderContext) => { + if (ctx.authType !== 'api_key') { + throw new AuthMissingError('ambientweather', 'api_key'); } - if (source === 'webhook') { - const res = await ctx.keys.get_webhook_signature(); - return res ?? ''; - } - - if (source === 'endpoint' && options.key) { - return options.key; - } + const [apiKey, applicationKey] = await Promise.all([ + ctx.keys.get_api_key(), + ctx.keys.get_applicationKey(), + ]); - if (source === 'endpoint' && ctx.authType === 'api_key') { - const res = await ctx.keys.get_api_key(); - return res ?? ''; + if (!apiKey || !applicationKey) { + throw new AuthMissingError('ambientweather', 'api_key'); } - if (source === 'endpoint' && ctx.authType === 'oauth_2') { - const res = await ctx.keys.get_access_token(); - return res ?? ''; - } - - return ''; + return packAmbientWeatherCredentials({ apiKey, applicationKey }); }, } satisfies InternalAmbientWeatherPlugin; } +export { + AmbientWeatherAPIError, + AmbientWeatherCredentialsSchema, + AmbientWeatherRateLimitError, + makeAmbientWeatherRequest, + packAmbientWeatherCredentials, + parseAmbientWeatherKey, +} from './client'; export type { + AmbientWeatherDataPoint, + AmbientWeatherDeviceInfo, + AmbientWeatherDeviceListItem, + AmbientWeatherDeviceListResponse, + AmbientWeatherDevicesGetDataInput, + AmbientWeatherDevicesListInput, AmbientWeatherEndpointInputs, AmbientWeatherEndpointOutputs, - ExampleGetInput, - ExampleGetResponse, } from './endpoints/types'; -export type { - AmbientWeatherWebhookOutputs, - ExampleEvent, -} from './webhooks/types'; diff --git a/packages/ambientweather/schema/database.ts b/packages/ambientweather/schema/database.ts index bf98a358e..cb0ff5c3b 100644 --- a/packages/ambientweather/schema/database.ts +++ b/packages/ambientweather/schema/database.ts @@ -1,9 +1 @@ -import { z } from 'zod'; - -// TODO: Define your database entities here -// export const AmbientWeatherExample = z.object({ -// id: z.string(), -// name: z.string(), -// created_at: z.coerce.date().nullable().optional(), -// }); -// export type AmbientWeatherExample = z.infer; +export {}; diff --git a/packages/ambientweather/tsconfig.json b/packages/ambientweather/tsconfig.json index 15e507a13..9653f2a43 100644 --- a/packages/ambientweather/tsconfig.json +++ b/packages/ambientweather/tsconfig.json @@ -14,7 +14,7 @@ "declarationMap": true, "skipLibCheck": true }, - "include": ["./**/*"], - "exclude": ["dist", "node_modules"], + "include": ["**/*.ts"], + "exclude": ["**/dist/**", "**/node_modules/**", "**/*.test.ts"], "references": [] } diff --git a/packages/ambientweather/webhooks/example.ts b/packages/ambientweather/webhooks/example.ts deleted file mode 100644 index 20bd38279..000000000 --- a/packages/ambientweather/webhooks/example.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { logEventFromContext } from 'corsair/core'; -import type { AmbientWeatherWebhooks } from '..'; -import { - createAmbientWeatherMatch, - verifyAmbientWeatherWebhookSignature, -} from './types'; - -export const example: AmbientWeatherWebhooks['example'] = { - match: createAmbientWeatherMatch('example'), - - handler: async (ctx, request) => { - const verification = verifyAmbientWeatherWebhookSignature(request, ctx.key); - if (!verification.valid) { - return { - success: false, - statusCode: 401, - error: verification.error || 'Signature verification failed', - }; - } - - const event = request.payload; - if (event.type !== 'example') { - return { success: true, data: undefined }; - } - - await logEventFromContext( - ctx, - 'ambientweather.webhook.example', - { ...event }, - 'completed', - ); - - return { success: true, data: event }; - }, -}; diff --git a/packages/ambientweather/webhooks/index.ts b/packages/ambientweather/webhooks/index.ts deleted file mode 100644 index a12134e8a..000000000 --- a/packages/ambientweather/webhooks/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { example } from './example'; - -export const ExampleWebhooks = { - example: example, -}; - -export * from './oauth-tenant-link'; -export * from './tenant-matcher'; -export * from './types'; diff --git a/packages/ambientweather/webhooks/oauth-tenant-link.ts b/packages/ambientweather/webhooks/oauth-tenant-link.ts deleted file mode 100644 index 7035fc23e..000000000 --- a/packages/ambientweather/webhooks/oauth-tenant-link.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { TokenResponse, WebhookTenantMatch } from 'corsair/core'; -import { asRecord, toExternalId } from 'corsair/core'; - -// TODO: Rename linkType 'tenant_external_id' to match pluginTenantWebhookMatcher. -// Called after OAuth to store the routing id on corsair_accounts.config. -export async function resolveAmbientWeatherOAuthWebhookTenantLink( - tokens: TokenResponse, -): Promise { - // TODO: Read from token response when the provider includes a stable id. - // const externalId = toExternalId(asRecord(tokens.team)?.id); - const externalId = toExternalId(tokens.tenant_external_id); - if (externalId) { - return { linkType: 'tenant_external_id', externalId }; - } - - const accessToken = tokens.access_token; - if (!accessToken) return null; - - // TODO: Fetch from provider API when the token response omits the id. - // const response = await fetch('https://api.example.com/me', { - // headers: { Authorization: `Bearer ${accessToken}` }, - // }); - // if (!response.ok) return null; - // const payload = (await response.json()) as { id?: string }; - // const fetchedId = toExternalId(payload.id); - // return fetchedId - // ? { linkType: 'tenant_external_id', externalId: fetchedId } - // : null; - - return null; -} diff --git a/packages/ambientweather/webhooks/tenant-matcher.ts b/packages/ambientweather/webhooks/tenant-matcher.ts deleted file mode 100644 index 9f4cf1eb5..000000000 --- a/packages/ambientweather/webhooks/tenant-matcher.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { RawWebhookRequest, WebhookTenantMatch } from 'corsair/core'; -import { asRecord, firstString, readBodyRecord } from 'corsair/core'; - -// TODO: Rename linkType 'tenant_external_id' to match the provider field -// (e.g. team_id, installation_id, organization_id). Must match authConfig.account -// and oauthWebhookTenantLinkResolver. -// Return null for URL verification / handshake payloads that have no tenant id. -export function matchAmbientWeatherTenantWebhook( - request: RawWebhookRequest, -): WebhookTenantMatch | null { - const body = readBodyRecord(request); - if (!body) return null; - - // TODO: Extract the stable external id from the webhook payload. - // Example: - // const externalId = firstString([body.tenant_external_id, asRecord(body.data)?.id]); - const externalId = firstString([ - body.tenant_external_id, - asRecord(body.data)?.tenant_external_id, - ]); - - if (!externalId) return null; - - return { linkType: 'tenant_external_id', externalId }; -} diff --git a/packages/ambientweather/webhooks/types.ts b/packages/ambientweather/webhooks/types.ts deleted file mode 100644 index 9e7928073..000000000 --- a/packages/ambientweather/webhooks/types.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { - CorsairWebhookMatcher, - RawWebhookRequest, - WebhookRequest, -} from 'corsair/core'; -import { z } from 'zod'; - -export const AmbientWeatherWebhookPayloadSchema = z.object({ - type: z.string(), - created_at: z.string(), - data: z.record(z.string(), z.unknown()), -}); - -export type AmbientWeatherWebhookPayload = z.infer< - typeof AmbientWeatherWebhookPayloadSchema ->; - -export const ExampleEventSchema = AmbientWeatherWebhookPayloadSchema.extend({ - type: z.literal('example'), - data: z - .object({ - id: z.string(), - }) - .loose(), -}); - -export type ExampleEvent = z.infer; - -export type AmbientWeatherWebhookOutputs = { - example: ExampleEvent; -}; - -function parseBody(body: unknown): Record | null { - if (typeof body === 'string') { - try { - const parsed = JSON.parse(body); - return parsed !== null && - typeof parsed === 'object' && - !Array.isArray(parsed) - ? (parsed as Record) - : null; - } catch { - return null; - } - } - return body !== null && typeof body === 'object' && !Array.isArray(body) - ? (body as Record) - : null; -} - -export function createAmbientWeatherMatch( - eventType: string, -): CorsairWebhookMatcher { - return (request: RawWebhookRequest) => { - const parsedBody = parseBody(request.body); - return parsedBody !== null && parsedBody.type === eventType; - }; -} - -export function verifyAmbientWeatherWebhookSignature( - request: WebhookRequest, - secret: string, -): { valid: boolean; error?: string } { - // TODO: Implement webhook signature verification - return { valid: true }; -} diff --git a/www/src/app/oss/u/[username]/page.tsx b/www/src/app/oss/u/[username]/page.tsx index 753c1d825..c454c62c2 100644 --- a/www/src/app/oss/u/[username]/page.tsx +++ b/www/src/app/oss/u/[username]/page.tsx @@ -28,7 +28,7 @@ export async function generateMetadata({ export default async function ContributorProfilePage({ params }: PageProps) { const { username } = await params; - let profile; + let profile: Awaited>; try { profile = await getCachedContributorProfile(username.toLowerCase()); } catch (error) { diff --git a/www/src/components/landing/menu/site-menu.tsx b/www/src/components/landing/menu/site-menu.tsx index eb534ce15..9e35ba36b 100644 --- a/www/src/components/landing/menu/site-menu.tsx +++ b/www/src/components/landing/menu/site-menu.tsx @@ -5,7 +5,7 @@ import Image from 'next/image'; import Link from 'next/link'; import { useCallback, useEffect, useState } from 'react'; import { APP_URL, DISCORD_URL, DOCS_URL, GITHUB_URL } from '@/lib/site-links'; -import { ArrowRightIcon, PlusCorner } from '../icons'; +import { PlusCorner } from '../icons'; function MenuIcon() { return ( From 9580eef93a8712d8d70d979e1d1236f98da12101 Mon Sep 17 00:00:00 2001 From: Meet Batra Date: Mon, 10 Aug 2026 13:40:57 +0530 Subject: [PATCH 3/9] chore: revert out-of-scope files --- demo/mcp/corsair.ts | 1 + www/src/components/landing/menu/site-menu.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/demo/mcp/corsair.ts b/demo/mcp/corsair.ts index 066808855..25a1e2cfb 100644 --- a/demo/mcp/corsair.ts +++ b/demo/mcp/corsair.ts @@ -1,5 +1,6 @@ import 'dotenv/config'; import { github } from '@corsair-dev/github'; +import { gmail } from '@corsair-dev/gmail'; import { linear } from '@corsair-dev/linear'; // import { googlecalendar } from '@corsair-dev/googlecalendar' import { slack } from '@corsair-dev/slack'; diff --git a/www/src/components/landing/menu/site-menu.tsx b/www/src/components/landing/menu/site-menu.tsx index 9e35ba36b..eb534ce15 100644 --- a/www/src/components/landing/menu/site-menu.tsx +++ b/www/src/components/landing/menu/site-menu.tsx @@ -5,7 +5,7 @@ import Image from 'next/image'; import Link from 'next/link'; import { useCallback, useEffect, useState } from 'react'; import { APP_URL, DISCORD_URL, DOCS_URL, GITHUB_URL } from '@/lib/site-links'; -import { PlusCorner } from '../icons'; +import { ArrowRightIcon, PlusCorner } from '../icons'; function MenuIcon() { return ( From 9beb81622b64257b9e1a55351c8857a0b8d92c38 Mon Sep 17 00:00:00 2001 From: Meet Batra Date: Mon, 10 Aug 2026 13:43:41 +0530 Subject: [PATCH 4/9] chore: remove out-of-scope docs and www files per gate rules --- docs/plugins/ambientweather/api.mdx | 91 ------------------- .../ambientweather/get-credentials.mdx | 40 -------- docs/plugins/ambientweather/overview.mdx | 80 ---------------- 3 files changed, 211 deletions(-) delete mode 100644 docs/plugins/ambientweather/api.mdx delete mode 100644 docs/plugins/ambientweather/get-credentials.mdx delete mode 100644 docs/plugins/ambientweather/overview.mdx diff --git a/docs/plugins/ambientweather/api.mdx b/docs/plugins/ambientweather/api.mdx deleted file mode 100644 index 91d67d20a..000000000 --- a/docs/plugins/ambientweather/api.mdx +++ /dev/null @@ -1,91 +0,0 @@ ---- -title: API Reference -description: "Ambient Weather API reference for Corsair" ---- - -Every `ambientweather.api.*` operation is listed below with the request shape and return type. - - -**New to Corsair?** See [API access](/concepts/api), [authentication](/concepts/auth), and [error handling](/concepts/error-handling). - - -## devices.list - -`devices.list` - -**Method:** `GET /v1/devices` - -**Auth:** `apiKey` + `applicationKey` as query params, injected automatically - -**Returns:** `DeviceListItem[]` - -Each `DeviceListItem` contains: - -- `macAddress` (`string`) -- `info.name` (`string`) -- `info.location` (`string`) -- `lastData` (`WeatherData`) - -**Required params:** None - -**Example** - -```ts -await corsair.ambientweather.api.devices.list(); -``` - -## devices.getData - -`devices.getData` - -**Method:** `GET /v1/devices/:macAddress` - -**Auth:** `apiKey` + `applicationKey` as query params, injected automatically - -**Required params** - -| Name | Type | Description | -|------|------|-------------| -| `macAddress` | `string` | MAC address of the station | - -**Optional params** - -| Name | Type | Description | -|------|------|-------------| -| `limit` | `number` | 1-288, default `1` | -| `endDate` | `number` | Unix timestamp in milliseconds to end the data range | - -**Returns:** `WeatherData[]` - -Data is returned in chronological order and typically comes back in 5 or 30 minute increments. - -**Rate limit note:** Ambient Weather allows at most 1 request per second. If that limit is exceeded, Corsair throws `AmbientWeatherRateLimitError`. - -**Example** - -```ts -await corsair.ambientweather.api.devices.getData({ - macAddress: 'AA:BB:CC:DD:EE:FF', - limit: 24, -}); -``` - -## WeatherData shape - -The most important fields in `WeatherData` are: - -- `dateutc` -- `tempf` -- `humidity` -- `windspeedmph` -- `windgustmph` -- `winddir` -- `uv` -- `solarradiation` -- `dailyrainin` -- `baromrelin` -- `feelsLike` -- `dewPoint` -- `date` (`ISO string`) - -Full spec: https://github.com/ambient-weather/api-docs/wiki/Device-Data-Specs diff --git a/docs/plugins/ambientweather/get-credentials.mdx b/docs/plugins/ambientweather/get-credentials.mdx deleted file mode 100644 index 8669cc90a..000000000 --- a/docs/plugins/ambientweather/get-credentials.mdx +++ /dev/null @@ -1,40 +0,0 @@ ---- -title: Get Credentials -description: "How to get Ambient Weather API credentials" ---- - -## Get your Ambient Weather keys - - - - -Go to [ambientweather.net](https://ambientweather.net) and sign in to your account. - - - -From your account menu, open **Account Settings**. - - - -Scroll to the **API Keys** section. - - - -Create an API key. This is your `apiKey`. - - - -Create an Application key. This is your `applicationKey`, and you need one per app you build. - - - -Use these credential field names with `pnpm corsair setup`: - -```bash -pnpm corsair setup --plugin=ambientweather api_key=your-api-key application_key=your-application-key -``` - - - - -Both fields are required. Neither expires or needs refresh. diff --git a/docs/plugins/ambientweather/overview.mdx b/docs/plugins/ambientweather/overview.mdx deleted file mode 100644 index e67073359..000000000 --- a/docs/plugins/ambientweather/overview.mdx +++ /dev/null @@ -1,80 +0,0 @@ ---- -title: Overview -description: "Ambient Weather plugin for Corsair" ---- - -Use **Ambient Weather** through Corsair: one client, typed API calls, and no webhook surface. - -**What you get:** - -- 2 typed API operations -- 0 webhook event types -- No OAuth - -## Setup - - - - -```bash -pnpm install @corsair-dev/ambientweather -``` - - - -```ts corsair.ts -import { createCorsair } from 'corsair'; -import { ambientweather } from '@corsair-dev/ambientweather'; - -export const corsair = createCorsair({ - // ... other config options, - plugins: [ambientweather()], -}); -``` - - - -```bash -pnpm corsair setup --plugin=ambientweather api_key= application_key= -``` - - - - -## Authentication - - - - -Ambient Weather uses API key auth, not OAuth. Corsair sends both `apiKey` and `applicationKey` as query parameters on every request. There is no redirect flow and no token refresh flow. - -```ts corsair.ts -ambientweather() -``` - - - - -## Example API calls - -**List stations** - -```ts -const stations = await corsair.ambientweather.api.devices.list(); -``` - -**Fetch station history** - -```ts -const history = await corsair.ambientweather.api.devices.getData({ - macAddress: 'AA:BB:CC:DD:EE:FF', - limit: 24, -}); -``` - -## Reference - -| Topic | Link | -|-------|------| -| API | [API Reference](/plugins/ambientweather/api) | -| Credentials | [Get credentials](/plugins/ambientweather/get-credentials) | From b53453d7e0e644ae20d1cf262293eae8b9cf1f45 Mon Sep 17 00:00:00 2001 From: Meet Batra Date: Mon, 10 Aug 2026 13:52:42 +0530 Subject: [PATCH 5/9] chore: revert out-of-scope www file --- www/src/app/oss/u/[username]/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/src/app/oss/u/[username]/page.tsx b/www/src/app/oss/u/[username]/page.tsx index c454c62c2..753c1d825 100644 --- a/www/src/app/oss/u/[username]/page.tsx +++ b/www/src/app/oss/u/[username]/page.tsx @@ -28,7 +28,7 @@ export async function generateMetadata({ export default async function ContributorProfilePage({ params }: PageProps) { const { username } = await params; - let profile: Awaited>; + let profile; try { profile = await getCachedContributorProfile(username.toLowerCase()); } catch (error) { From a2d85e66a860d92e703c52e8031087beca7f67b6 Mon Sep 17 00:00:00 2001 From: Dhirender Choudhary Date: Wed, 12 Aug 2026 02:51:46 +0530 Subject: [PATCH 6/9] fix(ambientweather): align schema and host with API docs --- packages/ambientweather/api.test.ts | 126 +++++++++++-------- packages/ambientweather/client.ts | 3 +- packages/ambientweather/endpoints/devices.ts | 76 ++++++++--- packages/ambientweather/endpoints/types.ts | 41 +++--- packages/ambientweather/error-handlers.ts | 5 +- packages/ambientweather/schema.test.ts | 14 +-- packages/ambientweather/schema/database.ts | 76 ++++++++++- packages/ambientweather/schema/index.ts | 13 +- packages/corsair/core/constants.ts | 2 +- 9 files changed, 254 insertions(+), 102 deletions(-) diff --git a/packages/ambientweather/api.test.ts b/packages/ambientweather/api.test.ts index f4ba81522..29c345002 100644 --- a/packages/ambientweather/api.test.ts +++ b/packages/ambientweather/api.test.ts @@ -25,6 +25,26 @@ jest.mock('corsair/http', () => { const mockedRequest = request as jest.MockedFunction; +const sampleDevice = { + macAddress: '00:11:22:33:44:55', + info: { + name: 'Backyard Station', + location: 'Patio', + }, + lastData: { + dateutc: 1720000000000, + date: '2018-01-08T18:35:00.000Z', + tz: 'America/Los_Angeles', + tempf: 66.9, + humidity: 30, + winddir: 58, + windspeedmph: 0.9, + yearlyrainin: 0, + feelsLike: 66.9, + dewPoint: 34.45, + }, +}; + describe('ambientweather client', () => { beforeEach(() => { mockedRequest.mockReset(); @@ -42,24 +62,7 @@ describe('ambientweather client', () => { }); it('adds both auth query params on every request', async () => { - const sampleResponse = [ - { - macAddress: '00:11:22:33:44:55', - info: { - name: 'Backyard Station', - location: 'Patio', - }, - lastData: { - dateutc: 1720000000000, - date: '2024-07-03 12:00:00', - tz: 'America/Los_Angeles', - tempf: 72.5, - humidity: 44, - }, - }, - ]; - - mockedRequest.mockResolvedValueOnce(sampleResponse); + mockedRequest.mockResolvedValueOnce([sampleDevice]); const response = await makeAmbientWeatherRequest( '/v1/devices', @@ -79,7 +82,7 @@ describe('ambientweather client', () => { expect(firstCall).toBeDefined(); const [config, requestOptions, requestConfig] = firstCall!; - expect(config.BASE).toBe('https://api.ambientweather.net'); + expect(config.BASE).toBe('https://rt.ambientweather.net'); expect(requestOptions.url).toBe('/v1/devices'); expect(requestOptions.query).toEqual({ limit: 1, @@ -93,7 +96,7 @@ describe('ambientweather client', () => { const apiError = new ApiError( { method: 'GET', url: '/v1/devices' } as ApiRequestOptions, { - url: 'https://api.ambientweather.net/v1/devices', + url: 'https://rt.ambientweather.net/v1/devices', ok: false, status: 429, statusText: 'Too Many Requests', @@ -125,32 +128,17 @@ describe('ambientweather endpoints', () => { mockedRequest.mockReset(); }); - it('lists devices using the packed account credentials', async () => { - const response = [ - { - macAddress: '00:11:22:33:44:55', - info: { - name: 'Backyard Station', - location: 'Patio', - }, - lastData: { - dateutc: 1720000000000, - date: '2024-07-03 12:00:00', - tz: 'America/Los_Angeles', - tempf: 72.5, - humidity: 44, - }, - }, - ]; - - mockedRequest.mockResolvedValueOnce(response); + it('lists devices and upserts them into the devices entity', async () => { + mockedRequest.mockResolvedValueOnce([sampleDevice]); + const upsertByEntityId = jest.fn().mockResolvedValue(undefined); const ctx = { key: packAmbientWeatherCredentials({ apiKey: 'user-api-key', applicationKey: 'developer-app-key', }), - } as Parameters[0]; + db: { devices: { upsertByEntityId } }, + } as unknown as Parameters[0]; const parsed = await list(ctx, {}); @@ -163,31 +151,45 @@ describe('ambientweather endpoints', () => { applicationKey: 'developer-app-key', }, }); + expect(upsertByEntityId).toHaveBeenCalledWith('00:11:22:33:44:55', { + macAddress: '00:11:22:33:44:55', + name: 'Backyard Station', + location: 'Patio', + dateutc: 1720000000000, + date: '2018-01-08T18:35:00.000Z', + tz: 'America/Los_Angeles', + tempf: 66.9, + humidity: 30, + winddir: 58, + windspeedmph: 0.9, + yearlyrainin: 0, + feelsLike: 66.9, + dewPoint: 34.45, + }); }); - it('fetches device history with path params and query args', async () => { - const response = [ - { - dateutc: 1720000000000, - date: '2024-07-03 12:00:00', - tz: 'America/Los_Angeles', - tempf: 72.5, - humidity: 44, - }, - ]; - - mockedRequest.mockResolvedValueOnce(response); + it('fetches device history, omits unset limit, and upserts readings', async () => { + const reading = { + dateutc: 1720000000000, + date: '2018-01-08T18:35:00.000Z', + tz: 'America/Los_Angeles', + tempf: 66.9, + humidity: 30, + yearlyrainin: 0, + }; + mockedRequest.mockResolvedValueOnce([reading]); + const upsertByEntityId = jest.fn().mockResolvedValue(undefined); const ctx = { key: packAmbientWeatherCredentials({ apiKey: 'user-api-key', applicationKey: 'developer-app-key', }), - } as Parameters[0]; + db: { readings: { upsertByEntityId } }, + } as unknown as Parameters[0]; const parsed = await getData(ctx, { macAddress: '00:11:22:33:44:55', - limit: 12, endDate: 1720000000000, }); @@ -198,10 +200,22 @@ describe('ambientweather endpoints', () => { query: { apiKey: 'user-api-key', applicationKey: 'developer-app-key', - limit: 12, endDate: 1720000000000, }, }); + expect(mockedRequest.mock.calls[0]?.[1].query).not.toHaveProperty('limit'); + expect(upsertByEntityId).toHaveBeenCalledWith( + '00:11:22:33:44:55:1720000000000', + { + macAddress: '00:11:22:33:44:55', + dateutc: 1720000000000, + date: '2018-01-08T18:35:00.000Z', + tz: 'America/Los_Angeles', + tempf: 66.9, + humidity: 30, + yearlyrainin: 0, + }, + ); }); }); @@ -211,6 +225,8 @@ describe('ambientweather factory', () => { expect(plugin.id).toBe('ambientweather'); expect(plugin.authConfig).toEqual(ambientweatherAuthConfig); + expect(plugin.schema?.entities).toHaveProperty('devices'); + expect(plugin.schema?.entities).toHaveProperty('readings'); expect(plugin.endpoints).toBeDefined(); expect(plugin.endpoints!.devices.list).toEqual(expect.any(Function)); expect(plugin.endpoints!.devices.getData).toEqual(expect.any(Function)); diff --git a/packages/ambientweather/client.ts b/packages/ambientweather/client.ts index 221594ede..d3300471b 100644 --- a/packages/ambientweather/client.ts +++ b/packages/ambientweather/client.ts @@ -51,7 +51,8 @@ export class AmbientWeatherRateLimitError extends AmbientWeatherAPIError { } } -const AMBIENTWEATHER_API_BASE = 'https://api.ambientweather.net'; +// Official REST host per https://ambientweather.docs.apiary.io/ (api. also aliases) +const AMBIENTWEATHER_API_BASE = 'https://rt.ambientweather.net'; const AMBIENTWEATHER_RATE_LIMIT_CONFIG: RateLimitConfig = { enabled: true, diff --git a/packages/ambientweather/endpoints/devices.ts b/packages/ambientweather/endpoints/devices.ts index 62ee34981..a282b759e 100644 --- a/packages/ambientweather/endpoints/devices.ts +++ b/packages/ambientweather/endpoints/devices.ts @@ -1,7 +1,12 @@ import { AuthMissingError } from 'corsair/core'; import type { AmbientWeatherEndpoints } from '..'; import { makeAmbientWeatherRequest, parseAmbientWeatherKey } from '../client'; +import { pickAmbientWeatherReadingFields } from '../schema/database'; import type { AmbientWeatherEndpointOutputs } from './types'; +import { + AmbientWeatherDeviceDataResponseSchema, + AmbientWeatherDeviceListResponseSchema, +} from './types'; function requireAmbientWeatherCredentials(key: string): { apiKey: string; @@ -16,15 +21,34 @@ function requireAmbientWeatherCredentials(key: string): { export const list: AmbientWeatherEndpoints['devicesList'] = async ( ctx, - input, + _input, ) => { const { apiKey, applicationKey } = requireAmbientWeatherCredentials(ctx.key); - return makeAmbientWeatherRequest< - AmbientWeatherEndpointOutputs['devicesList'] - >('/v1/devices', apiKey, applicationKey, { - query: input, - }); + const response = AmbientWeatherDeviceListResponseSchema.parse( + await makeAmbientWeatherRequest< + AmbientWeatherEndpointOutputs['devicesList'] + >('/v1/devices', apiKey, applicationKey), + ); + + if (ctx.db.devices) { + for (const device of response) { + try { + await ctx.db.devices.upsertByEntityId(device.macAddress, { + macAddress: device.macAddress, + name: device.info.name, + location: device.info.location, + ...pickAmbientWeatherReadingFields( + device.lastData as Record, + ), + }); + } catch (error) { + console.warn('Failed to save Ambient Weather device:', error); + } + } + } + + return response; }; export const getData: AmbientWeatherEndpoints['devicesGetData'] = async ( @@ -33,17 +57,35 @@ export const getData: AmbientWeatherEndpoints['devicesGetData'] = async ( ) => { const { apiKey, applicationKey } = requireAmbientWeatherCredentials(ctx.key); - return makeAmbientWeatherRequest< - AmbientWeatherEndpointOutputs['devicesGetData'] - >( - `/v1/devices/${encodeURIComponent(input.macAddress)}`, - apiKey, - applicationKey, - { - query: { - limit: input.limit, - endDate: input.endDate, + const response = AmbientWeatherDeviceDataResponseSchema.parse( + await makeAmbientWeatherRequest< + AmbientWeatherEndpointOutputs['devicesGetData'] + >( + `/v1/devices/${encodeURIComponent(input.macAddress)}`, + apiKey, + applicationKey, + { + query: { + ...(input.limit !== undefined ? { limit: input.limit } : {}), + ...(input.endDate !== undefined ? { endDate: input.endDate } : {}), + }, }, - }, + ), ); + + if (ctx.db.readings) { + for (const point of response) { + try { + const entityId = `${input.macAddress}:${point.dateutc}`; + await ctx.db.readings.upsertByEntityId(entityId, { + macAddress: input.macAddress, + ...pickAmbientWeatherReadingFields(point as Record), + }); + } catch (error) { + console.warn('Failed to save Ambient Weather reading:', error); + } + } + } + + return response; }; diff --git a/packages/ambientweather/endpoints/types.ts b/packages/ambientweather/endpoints/types.ts index 2f6eb4b35..c1ed85494 100644 --- a/packages/ambientweather/endpoints/types.ts +++ b/packages/ambientweather/endpoints/types.ts @@ -7,34 +7,44 @@ export const AmbientWeatherDeviceInfoSchema = z }) .passthrough(); +/** + * Device data point fields from the Ambient Weather REST sample + wiki. + * `.passthrough()` keeps station-specific sensors (temp1f, pm25, …). + * https://ambientweather.docs.apiary.io/ + * https://github.com/ambient-weather/api-docs/wiki/Device-Data-Specs + */ export const AmbientWeatherDataPointSchema = z .object({ dateutc: z.number().int(), - tempf: z.number().optional(), - humidity: z.number().optional(), + date: z.string().optional(), + tz: z.string().optional(), + winddir: z.number().optional(), windspeedmph: z.number().optional(), windgustmph: z.number().optional(), maxdailygust: z.number().optional(), - winddir: z.number().optional(), - uv: z.number().optional(), - solarradiation: z.number().optional(), + windgustdir: z.number().optional(), + winddir_avg2m: z.number().optional(), + windspdmph_avg2m: z.number().optional(), + winddir_avg10m: z.number().optional(), + windspdmph_avg10m: z.number().optional(), + tempf: z.number().optional(), + humidity: z.number().optional(), + baromrelin: z.number().optional(), + baromabsin: z.number().optional(), + tempinf: z.number().optional(), + humidityin: z.number().optional(), hourlyrainin: z.number().optional(), - eventrainin: z.number().optional(), dailyrainin: z.number().optional(), weeklyrainin: z.number().optional(), monthlyrainin: z.number().optional(), + yearlyrainin: z.number().optional(), + eventrainin: z.number().optional(), totalrainin: z.number().optional(), - baromrelin: z.number().optional(), - baromabsin: z.number().optional(), - tempinf: z.number().optional(), - humidityin: z.number().optional(), + uv: z.number().optional(), + solarradiation: z.number().optional(), feelsLike: z.number().optional(), dewPoint: z.number().optional(), lastRain: z.string().optional(), - tz: z.string().optional(), - date: z.string().optional(), - loc: z.string().optional(), - time: z.number().optional(), }) .passthrough(); @@ -56,9 +66,10 @@ export const AmbientWeatherDeviceDataResponseSchema = z.array( export const AmbientWeatherDevicesListInputSchema = z.object({}); +/** Docs: limit max 288; omit to use Ambient Weather's default (288). */ export const AmbientWeatherDevicesGetDataInputSchema = z.object({ macAddress: z.string().min(1), - limit: z.coerce.number().int().min(1).max(288).default(1), + limit: z.coerce.number().int().min(1).max(288).optional(), endDate: z.coerce.number().int().optional(), }); diff --git a/packages/ambientweather/error-handlers.ts b/packages/ambientweather/error-handlers.ts index 751a57542..b1d520229 100644 --- a/packages/ambientweather/error-handlers.ts +++ b/packages/ambientweather/error-handlers.ts @@ -33,8 +33,9 @@ export const errorHandlers = { return ( message.includes('unauthorized') || message.includes('forbidden') || - message.includes('invalid') || - message.includes('authentication') + message.includes('authentication') || + message.includes('apikey') || + message.includes('applicationkey') ); }, handler: async () => ({ maxRetries: 0 }), diff --git a/packages/ambientweather/schema.test.ts b/packages/ambientweather/schema.test.ts index 47f834bf4..365ea4246 100644 --- a/packages/ambientweather/schema.test.ts +++ b/packages/ambientweather/schema.test.ts @@ -6,17 +6,13 @@ describe('AmbientWeather schema', () => { expect(AmbientWeatherSchema.version).toMatch(/^\d+\.\d+\.\d+$/); }); - it('declares an entities map', () => { - expect(typeof AmbientWeatherSchema.entities).toBe('object'); - expect(AmbientWeatherSchema.entities).not.toBeNull(); - expect(Array.isArray(Object.keys(AmbientWeatherSchema.entities))).toBe( - true, - ); + it('declares devices and readings entities from Ambient Weather docs', () => { + expect(Object.keys(AmbientWeatherSchema.entities).sort()).toEqual([ + 'devices', + 'readings', + ]); for (const entity of Object.values(AmbientWeatherSchema.entities)) { expect(entity).toBeDefined(); } }); }); - -// Per .github/PLUGIN_PR_RULES.md (R2), every implemented endpoint -// needs a corresponding test. diff --git a/packages/ambientweather/schema/database.ts b/packages/ambientweather/schema/database.ts index cb0ff5c3b..39fc5068a 100644 --- a/packages/ambientweather/schema/database.ts +++ b/packages/ambientweather/schema/database.ts @@ -1 +1,75 @@ -export {}; +import { z } from 'zod'; + +/** + * Core Ambient Weather reading fields from the REST API docs sample + wiki. + * Devices emit a subset; unknown sensor keys are not stored on the local row. + * https://github.com/ambient-weather/api-docs/wiki/Device-Data-Specs + */ +const AmbientWeatherReadingFields = { + dateutc: z.number().int(), + date: z.string().optional(), + tz: z.string().optional(), + winddir: z.number().optional(), + windspeedmph: z.number().optional(), + windgustmph: z.number().optional(), + maxdailygust: z.number().optional(), + windgustdir: z.number().optional(), + winddir_avg2m: z.number().optional(), + windspdmph_avg2m: z.number().optional(), + winddir_avg10m: z.number().optional(), + windspdmph_avg10m: z.number().optional(), + tempf: z.number().optional(), + humidity: z.number().optional(), + baromrelin: z.number().optional(), + baromabsin: z.number().optional(), + tempinf: z.number().optional(), + humidityin: z.number().optional(), + hourlyrainin: z.number().optional(), + dailyrainin: z.number().optional(), + weeklyrainin: z.number().optional(), + monthlyrainin: z.number().optional(), + yearlyrainin: z.number().optional(), + eventrainin: z.number().optional(), + totalrainin: z.number().optional(), + uv: z.number().optional(), + solarradiation: z.number().optional(), + feelsLike: z.number().optional(), + dewPoint: z.number().optional(), + lastRain: z.string().optional(), +} as const; + +/** + * Local cache of an Ambient Weather station and its latest reading. + * Synced from GET /v1/devices. + */ +export const AmbientWeatherDevice = z.object({ + macAddress: z.string(), + name: z.string(), + location: z.string().optional(), + ...AmbientWeatherReadingFields, + dateutc: z.number().int().optional(), + checkedAt: z.coerce.date().nullable().optional(), +}); + +/** + * Local cache of a historical reading for a station. + * Synced from GET /v1/devices/{macAddress}. + */ +export const AmbientWeatherReading = z.object({ + macAddress: z.string(), + ...AmbientWeatherReadingFields, + checkedAt: z.coerce.date().nullable().optional(), +}); + +export type AmbientWeatherDevice = z.infer; +export type AmbientWeatherReading = z.infer; + +export function pickAmbientWeatherReadingFields( + data: Record, +): Omit { + const out: Record = {}; + for (const key of Object.keys(AmbientWeatherReadingFields)) { + if (key in data) out[key] = data[key]; + } + return out as Omit; +} diff --git a/packages/ambientweather/schema/index.ts b/packages/ambientweather/schema/index.ts index 83a0e2cd7..238c87ebe 100644 --- a/packages/ambientweather/schema/index.ts +++ b/packages/ambientweather/schema/index.ts @@ -1,4 +1,15 @@ +import { AmbientWeatherDevice, AmbientWeatherReading } from './database'; + export const AmbientWeatherSchema = { version: '1.0.0', - entities: {}, + entities: { + devices: AmbientWeatherDevice, + readings: AmbientWeatherReading, + }, } as const; + +export { + AmbientWeatherDevice, + AmbientWeatherReading, + pickAmbientWeatherReadingFields, +} from './database'; diff --git a/packages/corsair/core/constants.ts b/packages/corsair/core/constants.ts index 8fa6bc159..10fba8ac4 100644 --- a/packages/corsair/core/constants.ts +++ b/packages/corsair/core/constants.ts @@ -128,7 +128,7 @@ export const ProviderDisplayNames = { airtable: 'Airtable', algolia: 'Algolia', alttextai: 'AltText.ai', - ambientweather: 'AmbientWeather', + ambientweather: 'Ambient Weather', amplitude: 'Amplitude', apilabz: 'API Labz', asana: 'Asana', From 201ffce8ad7c3569b5536ea629fba2d361e6a27b Mon Sep 17 00:00:00 2001 From: Dhirender Choudhary Date: Wed, 12 Aug 2026 02:58:05 +0530 Subject: [PATCH 7/9] fix(ambientweather): use path params to clear CodeQL ReDoS --- packages/ambientweather/api.test.ts | 3 ++- packages/ambientweather/client.ts | 4 ++++ packages/ambientweather/endpoints/devices.ts | 16 ++++++---------- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/ambientweather/api.test.ts b/packages/ambientweather/api.test.ts index 29c345002..cff5ad85f 100644 --- a/packages/ambientweather/api.test.ts +++ b/packages/ambientweather/api.test.ts @@ -196,7 +196,8 @@ describe('ambientweather endpoints', () => { AmbientWeatherDeviceDataResponseSchema.parse(parsed); expect(mockedRequest).toHaveBeenCalledTimes(1); expect(mockedRequest.mock.calls[0]?.[1]).toMatchObject({ - url: '/v1/devices/00%3A11%3A22%3A33%3A44%3A55', + url: '/v1/devices/{macAddress}', + path: { macAddress: '00:11:22:33:44:55' }, query: { apiKey: 'user-api-key', applicationKey: 'developer-app-key', diff --git a/packages/ambientweather/client.ts b/packages/ambientweather/client.ts index d3300471b..0c7dccbf4 100644 --- a/packages/ambientweather/client.ts +++ b/packages/ambientweather/client.ts @@ -94,6 +94,7 @@ export async function makeAmbientWeatherRequest( apiKey: string, applicationKey: string, options: { + path?: Record; query?: AmbientWeatherRequestQuery; } = {}, ): Promise { @@ -103,6 +104,8 @@ export async function makeAmbientWeatherRequest( WITH_CREDENTIALS: false, CREDENTIALS: 'omit', TOKEN: undefined, + // Keep macAddress colons percent-encoded (same as encodeURIComponent). + ENCODE_PATH: encodeURIComponent, HEADERS: { Accept: 'application/json', 'Content-Type': 'application/json', @@ -112,6 +115,7 @@ export async function makeAmbientWeatherRequest( const requestOptions: ApiRequestOptions = { method: 'GET', url: endpoint, + path: options.path, query: { ...options.query, apiKey, diff --git a/packages/ambientweather/endpoints/devices.ts b/packages/ambientweather/endpoints/devices.ts index a282b759e..561496fbd 100644 --- a/packages/ambientweather/endpoints/devices.ts +++ b/packages/ambientweather/endpoints/devices.ts @@ -60,17 +60,13 @@ export const getData: AmbientWeatherEndpoints['devicesGetData'] = async ( const response = AmbientWeatherDeviceDataResponseSchema.parse( await makeAmbientWeatherRequest< AmbientWeatherEndpointOutputs['devicesGetData'] - >( - `/v1/devices/${encodeURIComponent(input.macAddress)}`, - apiKey, - applicationKey, - { - query: { - ...(input.limit !== undefined ? { limit: input.limit } : {}), - ...(input.endDate !== undefined ? { endDate: input.endDate } : {}), - }, + >('/v1/devices/{macAddress}', apiKey, applicationKey, { + path: { macAddress: input.macAddress }, + query: { + ...(input.limit !== undefined ? { limit: input.limit } : {}), + ...(input.endDate !== undefined ? { endDate: input.endDate } : {}), }, - ), + }), ); if (ctx.db.readings) { From f708d0adfa3d3583bdd6ded2c16dac2289849b8c Mon Sep 17 00:00:00 2001 From: Dhirender Choudhary Date: Wed, 12 Aug 2026 03:04:39 +0530 Subject: [PATCH 8/9] fix(ambientweather): use fetch to clear CodeQL ReDoS path --- packages/ambientweather/api.test.ts | 131 ++++++--------- packages/ambientweather/client.ts | 160 ++++++++++--------- packages/ambientweather/endpoints/devices.ts | 22 +-- packages/ambientweather/schema/database.ts | 29 ++-- 4 files changed, 159 insertions(+), 183 deletions(-) diff --git a/packages/ambientweather/api.test.ts b/packages/ambientweather/api.test.ts index cff5ad85f..e97b372eb 100644 --- a/packages/ambientweather/api.test.ts +++ b/packages/ambientweather/api.test.ts @@ -1,6 +1,5 @@ -import type { ApiRequestOptions, ApiResult } from 'corsair/http'; -import { ApiError, request } from 'corsair/http'; import { + AmbientWeatherRateLimitError, makeAmbientWeatherRequest, packAmbientWeatherCredentials, parseAmbientWeatherKey, @@ -8,22 +7,27 @@ import { import { getData, list } from './endpoints/devices'; import { AmbientWeatherDeviceDataResponseSchema, - AmbientWeatherDeviceListResponseSchema, AmbientWeatherEndpointOutputSchemas, } from './endpoints/types'; import { ambientweather, ambientweatherAuthConfig } from './index'; -jest.mock('corsair/http', () => { - const actual = jest.requireActual( - 'corsair/http', - ) as typeof import('corsair/http'); - return { - ...actual, - request: jest.fn(), - }; +const mockFetch = jest.fn(); + +beforeAll(() => { + globalThis.fetch = mockFetch as typeof fetch; +}); + +beforeEach(() => { + mockFetch.mockReset(); }); -const mockedRequest = request as jest.MockedFunction; +function jsonResponse(body: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + ...init, + }); +} const sampleDevice = { macAddress: '00:11:22:33:44:55', @@ -46,10 +50,6 @@ const sampleDevice = { }; describe('ambientweather client', () => { - beforeEach(() => { - mockedRequest.mockReset(); - }); - it('packs and parses credentials for the key builder', () => { const credentials = { apiKey: 'user-api-key', @@ -62,74 +62,50 @@ describe('ambientweather client', () => { }); it('adds both auth query params on every request', async () => { - mockedRequest.mockResolvedValueOnce([sampleDevice]); + mockFetch.mockResolvedValueOnce(jsonResponse([sampleDevice])); const response = await makeAmbientWeatherRequest( '/v1/devices', 'user-api-key', 'developer-app-key', - { - query: { - limit: 1, - }, - }, + { query: { limit: 1 } }, ); - AmbientWeatherDeviceListResponseSchema.parse(response); - - expect(mockedRequest).toHaveBeenCalledTimes(1); - const firstCall = mockedRequest.mock.calls[0]; - expect(firstCall).toBeDefined(); - const [config, requestOptions, requestConfig] = firstCall!; - - expect(config.BASE).toBe('https://rt.ambientweather.net'); - expect(requestOptions.url).toBe('/v1/devices'); - expect(requestOptions.query).toEqual({ - limit: 1, - apiKey: 'user-api-key', - applicationKey: 'developer-app-key', - }); - expect(requestConfig?.rateLimitConfig).toMatchObject({ maxRetries: 0 }); + AmbientWeatherEndpointOutputSchemas.devicesList.parse(response); + expect(mockFetch).toHaveBeenCalledTimes(1); + + const called = mockFetch.mock.calls[0]?.[0]; + expect(called).toBeInstanceOf(URL); + const url = called as URL; + expect(url.origin).toBe('https://rt.ambientweather.net'); + expect(url.pathname).toBe('/v1/devices'); + expect(url.searchParams.get('apiKey')).toBe('user-api-key'); + expect(url.searchParams.get('applicationKey')).toBe('developer-app-key'); + expect(url.searchParams.get('limit')).toBe('1'); }); it('wraps 429 responses in AmbientWeatherRateLimitError', async () => { - const apiError = new ApiError( - { method: 'GET', url: '/v1/devices' } as ApiRequestOptions, - { - url: 'https://rt.ambientweather.net/v1/devices', - ok: false, - status: 429, - statusText: 'Too Many Requests', - body: { error: 'rate limited' }, - } as ApiResult, - 'Too Many Requests', - { retryAfter: 1000 }, + mockFetch.mockResolvedValueOnce( + jsonResponse( + { error: 'rate limited' }, + { status: 429, statusText: 'Too Many Requests' }, + ), ); - mockedRequest.mockRejectedValueOnce(apiError); - await expect( makeAmbientWeatherRequest( '/v1/devices', 'user-api-key', 'developer-app-key', ), - ).rejects.toMatchObject({ - name: 'AmbientWeatherRateLimitError', - code: 429, - status: 429, - }); - expect(mockedRequest).toHaveBeenCalledTimes(1); + ).rejects.toBeInstanceOf(AmbientWeatherRateLimitError); + expect(mockFetch).toHaveBeenCalledTimes(1); }); }); describe('ambientweather endpoints', () => { - beforeEach(() => { - mockedRequest.mockReset(); - }); - it('lists devices and upserts them into the devices entity', async () => { - mockedRequest.mockResolvedValueOnce([sampleDevice]); + mockFetch.mockResolvedValueOnce(jsonResponse([sampleDevice])); const upsertByEntityId = jest.fn().mockResolvedValue(undefined); const ctx = { @@ -143,14 +119,11 @@ describe('ambientweather endpoints', () => { const parsed = await list(ctx, {}); AmbientWeatherEndpointOutputSchemas.devicesList.parse(parsed); - expect(mockedRequest).toHaveBeenCalledTimes(1); - expect(mockedRequest.mock.calls[0]?.[1]).toMatchObject({ - url: '/v1/devices', - query: { - apiKey: 'user-api-key', - applicationKey: 'developer-app-key', - }, - }); + expect(mockFetch).toHaveBeenCalledTimes(1); + const url = mockFetch.mock.calls[0]?.[0] as URL; + expect(url.pathname).toBe('/v1/devices'); + expect(url.searchParams.get('apiKey')).toBe('user-api-key'); + expect(url.searchParams.get('applicationKey')).toBe('developer-app-key'); expect(upsertByEntityId).toHaveBeenCalledWith('00:11:22:33:44:55', { macAddress: '00:11:22:33:44:55', name: 'Backyard Station', @@ -177,7 +150,7 @@ describe('ambientweather endpoints', () => { humidity: 30, yearlyrainin: 0, }; - mockedRequest.mockResolvedValueOnce([reading]); + mockFetch.mockResolvedValueOnce(jsonResponse([reading])); const upsertByEntityId = jest.fn().mockResolvedValue(undefined); const ctx = { @@ -194,17 +167,13 @@ describe('ambientweather endpoints', () => { }); AmbientWeatherDeviceDataResponseSchema.parse(parsed); - expect(mockedRequest).toHaveBeenCalledTimes(1); - expect(mockedRequest.mock.calls[0]?.[1]).toMatchObject({ - url: '/v1/devices/{macAddress}', - path: { macAddress: '00:11:22:33:44:55' }, - query: { - apiKey: 'user-api-key', - applicationKey: 'developer-app-key', - endDate: 1720000000000, - }, - }); - expect(mockedRequest.mock.calls[0]?.[1].query).not.toHaveProperty('limit'); + expect(mockFetch).toHaveBeenCalledTimes(1); + const url = mockFetch.mock.calls[0]?.[0] as URL; + expect(url.pathname).toBe('/v1/devices/00%3A11%3A22%3A33%3A44%3A55'); + expect(url.searchParams.get('apiKey')).toBe('user-api-key'); + expect(url.searchParams.get('applicationKey')).toBe('developer-app-key'); + expect(url.searchParams.get('endDate')).toBe('1720000000000'); + expect(url.searchParams.has('limit')).toBe(false); expect(upsertByEntityId).toHaveBeenCalledWith( '00:11:22:33:44:55:1720000000000', { diff --git a/packages/ambientweather/client.ts b/packages/ambientweather/client.ts index 0c7dccbf4..82777d20d 100644 --- a/packages/ambientweather/client.ts +++ b/packages/ambientweather/client.ts @@ -1,9 +1,3 @@ -import type { - ApiRequestOptions, - OpenAPIConfig, - RateLimitConfig, -} from 'corsair/http'; -import { ApiError, request } from 'corsair/http'; import { z } from 'zod'; export const AmbientWeatherCredentialsSchema = z.object({ @@ -20,50 +14,39 @@ export class AmbientWeatherAPIError extends Error { public readonly statusText?: string; public readonly body?: unknown; public readonly retryAfter?: number; - public readonly rateLimitReset?: number; - public readonly rateLimitRemaining?: number; - public readonly rateLimitLimit?: number; constructor( message: string, public readonly code?: number, - options?: { cause?: Error }, + options?: { + cause?: Error; + status?: number; + statusText?: string; + body?: unknown; + retryAfter?: number; + }, ) { super(message, options); this.name = 'AmbientWeatherAPIError'; - - if (options?.cause instanceof ApiError) { - this.status = options.cause.status; - this.statusText = options.cause.statusText; - this.body = options.cause.body; - this.retryAfter = options.cause.retryAfter; - this.rateLimitReset = options.cause.rateLimitReset; - this.rateLimitRemaining = options.cause.rateLimitRemaining; - this.rateLimitLimit = options.cause.rateLimitLimit; - } + this.status = options?.status ?? code; + this.statusText = options?.statusText; + this.body = options?.body; + this.retryAfter = options?.retryAfter; } } export class AmbientWeatherRateLimitError extends AmbientWeatherAPIError { - constructor(message: string, options?: { cause?: Error }) { + constructor( + message: string, + options?: ConstructorParameters[2], + ) { super(message, 429, options); this.name = 'AmbientWeatherRateLimitError'; } } -// Official REST host per https://ambientweather.docs.apiary.io/ (api. also aliases) const AMBIENTWEATHER_API_BASE = 'https://rt.ambientweather.net'; -const AMBIENTWEATHER_RATE_LIMIT_CONFIG: RateLimitConfig = { - enabled: true, - maxRetries: 0, - initialRetryDelay: 1000, - backoffMultiplier: 2, - headerNames: { - retryAfter: 'Retry-After', - }, -}; - export type AmbientWeatherQueryValue = string | number | boolean | undefined; export type AmbientWeatherRequestQuery = Record< @@ -89,62 +72,95 @@ export function parseAmbientWeatherKey( } } +function buildAmbientWeatherUrl( + endpoint: string, + apiKey: string, + applicationKey: string, + query?: AmbientWeatherRequestQuery, +): URL { + const path = endpoint.startsWith('/') ? endpoint.slice(1) : endpoint; + const url = new URL(path, `${AMBIENTWEATHER_API_BASE}/`); + const params: AmbientWeatherRequestQuery = { + ...query, + apiKey, + applicationKey, + }; + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) url.searchParams.set(key, String(value)); + } + return url; +} + export async function makeAmbientWeatherRequest( endpoint: string, apiKey: string, applicationKey: string, options: { - path?: Record; query?: AmbientWeatherRequestQuery; } = {}, ): Promise { - const config: OpenAPIConfig = { - BASE: AMBIENTWEATHER_API_BASE, - VERSION: '1.0.0', - WITH_CREDENTIALS: false, - CREDENTIALS: 'omit', - TOKEN: undefined, - // Keep macAddress colons percent-encoded (same as encodeURIComponent). - ENCODE_PATH: encodeURIComponent, - HEADERS: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - }; - - const requestOptions: ApiRequestOptions = { - method: 'GET', - url: endpoint, - path: options.path, - query: { - ...options.query, - apiKey, - applicationKey, - }, - }; - + const url = buildAmbientWeatherUrl( + endpoint, + apiKey, + applicationKey, + options.query, + ); + + let response: Response; try { - return await request(config, requestOptions, { - rateLimitConfig: AMBIENTWEATHER_RATE_LIMIT_CONFIG, + response = await fetch(url, { + method: 'GET', + headers: { Accept: 'application/json' }, }); } catch (error) { - if (error instanceof ApiError) { - if (error.status === 429) { - throw new AmbientWeatherRateLimitError(error.message, { - cause: error, - }); - } - throw new AmbientWeatherAPIError(error.message, error.status, { - cause: error, - }); - } - if (error instanceof Error) { throw new AmbientWeatherAPIError(error.message, undefined, { cause: error, }); } - throw new AmbientWeatherAPIError('Unknown Ambient Weather API error'); } + + if (!response.ok) { + let body: unknown; + try { + body = await response.json(); + } catch { + body = undefined; + } + + const retryAfterHeader = response.headers.get('Retry-After'); + const retryAfterMs = retryAfterHeader + ? Number(retryAfterHeader) * 1000 + : undefined; + const retryAfter = + retryAfterMs !== undefined && Number.isFinite(retryAfterMs) + ? retryAfterMs + : undefined; + + if (response.status === 429) { + throw new AmbientWeatherRateLimitError( + response.statusText || 'Too Many Requests', + { + status: 429, + statusText: response.statusText, + body, + retryAfter, + }, + ); + } + + throw new AmbientWeatherAPIError( + response.statusText || 'Ambient Weather API error', + response.status, + { + status: response.status, + statusText: response.statusText, + body, + retryAfter, + }, + ); + } + + return (await response.json()) as T; } diff --git a/packages/ambientweather/endpoints/devices.ts b/packages/ambientweather/endpoints/devices.ts index 561496fbd..134e375f3 100644 --- a/packages/ambientweather/endpoints/devices.ts +++ b/packages/ambientweather/endpoints/devices.ts @@ -38,9 +38,7 @@ export const list: AmbientWeatherEndpoints['devicesList'] = async ( macAddress: device.macAddress, name: device.info.name, location: device.info.location, - ...pickAmbientWeatherReadingFields( - device.lastData as Record, - ), + ...pickAmbientWeatherReadingFields(device.lastData), }); } catch (error) { console.warn('Failed to save Ambient Weather device:', error); @@ -60,13 +58,17 @@ export const getData: AmbientWeatherEndpoints['devicesGetData'] = async ( const response = AmbientWeatherDeviceDataResponseSchema.parse( await makeAmbientWeatherRequest< AmbientWeatherEndpointOutputs['devicesGetData'] - >('/v1/devices/{macAddress}', apiKey, applicationKey, { - path: { macAddress: input.macAddress }, - query: { - ...(input.limit !== undefined ? { limit: input.limit } : {}), - ...(input.endDate !== undefined ? { endDate: input.endDate } : {}), + >( + `/v1/devices/${encodeURIComponent(input.macAddress)}`, + apiKey, + applicationKey, + { + query: { + ...(input.limit !== undefined ? { limit: input.limit } : {}), + ...(input.endDate !== undefined ? { endDate: input.endDate } : {}), + }, }, - }), + ), ); if (ctx.db.readings) { @@ -75,7 +77,7 @@ export const getData: AmbientWeatherEndpoints['devicesGetData'] = async ( const entityId = `${input.macAddress}:${point.dateutc}`; await ctx.db.readings.upsertByEntityId(entityId, { macAddress: input.macAddress, - ...pickAmbientWeatherReadingFields(point as Record), + ...pickAmbientWeatherReadingFields(point), }); } catch (error) { console.warn('Failed to save Ambient Weather reading:', error); diff --git a/packages/ambientweather/schema/database.ts b/packages/ambientweather/schema/database.ts index 39fc5068a..49fb712ae 100644 --- a/packages/ambientweather/schema/database.ts +++ b/packages/ambientweather/schema/database.ts @@ -2,10 +2,9 @@ import { z } from 'zod'; /** * Core Ambient Weather reading fields from the REST API docs sample + wiki. - * Devices emit a subset; unknown sensor keys are not stored on the local row. * https://github.com/ambient-weather/api-docs/wiki/Device-Data-Specs */ -const AmbientWeatherReadingFields = { +const AmbientWeatherReadingFieldsSchema = z.object({ dateutc: z.number().int(), date: z.string().optional(), tz: z.string().optional(), @@ -36,28 +35,22 @@ const AmbientWeatherReadingFields = { feelsLike: z.number().optional(), dewPoint: z.number().optional(), lastRain: z.string().optional(), -} as const; +}); -/** - * Local cache of an Ambient Weather station and its latest reading. - * Synced from GET /v1/devices. - */ +/** Latest-reading cache row from GET /v1/devices. */ export const AmbientWeatherDevice = z.object({ macAddress: z.string(), name: z.string(), location: z.string().optional(), - ...AmbientWeatherReadingFields, + ...AmbientWeatherReadingFieldsSchema.shape, dateutc: z.number().int().optional(), checkedAt: z.coerce.date().nullable().optional(), }); -/** - * Local cache of a historical reading for a station. - * Synced from GET /v1/devices/{macAddress}. - */ +/** Historical reading cache row from GET /v1/devices/{macAddress}. */ export const AmbientWeatherReading = z.object({ macAddress: z.string(), - ...AmbientWeatherReadingFields, + ...AmbientWeatherReadingFieldsSchema.shape, checkedAt: z.coerce.date().nullable().optional(), }); @@ -65,11 +58,7 @@ export type AmbientWeatherDevice = z.infer; export type AmbientWeatherReading = z.infer; export function pickAmbientWeatherReadingFields( - data: Record, -): Omit { - const out: Record = {}; - for (const key of Object.keys(AmbientWeatherReadingFields)) { - if (key in data) out[key] = data[key]; - } - return out as Omit; + data: unknown, +): z.infer { + return AmbientWeatherReadingFieldsSchema.parse(data); } From 5da0a9c4ff749ae644dc77bf58b39909fc84439a Mon Sep 17 00:00:00 2001 From: Dhirender Choudhary Date: Wed, 12 Aug 2026 03:12:19 +0530 Subject: [PATCH 9/9] fix(ambientweather): parse Retry-After HTTP-date --- packages/ambientweather/api.test.ts | 44 +++++++++++++++++++++++++++++ packages/ambientweather/client.ts | 25 ++++++++++------ 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/packages/ambientweather/api.test.ts b/packages/ambientweather/api.test.ts index e97b372eb..12f51cf26 100644 --- a/packages/ambientweather/api.test.ts +++ b/packages/ambientweather/api.test.ts @@ -101,6 +101,50 @@ describe('ambientweather client', () => { ).rejects.toBeInstanceOf(AmbientWeatherRateLimitError); expect(mockFetch).toHaveBeenCalledTimes(1); }); + + it('parses Retry-After delta-seconds and HTTP-date', async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ error: 'rate limited' }), { + status: 429, + statusText: 'Too Many Requests', + headers: { + 'Content-Type': 'application/json', + 'Retry-After': '2', + }, + }), + ); + + await expect( + makeAmbientWeatherRequest( + '/v1/devices', + 'user-api-key', + 'developer-app-key', + ), + ).rejects.toMatchObject({ + name: 'AmbientWeatherRateLimitError', + retryAfter: 2000, + }); + + const future = new Date(Date.now() + 5000).toUTCString(); + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ error: 'rate limited' }), { + status: 429, + statusText: 'Too Many Requests', + headers: { + 'Content-Type': 'application/json', + 'Retry-After': future, + }, + }), + ); + + const err = await makeAmbientWeatherRequest( + '/v1/devices', + 'user-api-key', + 'developer-app-key', + ).catch((error: unknown) => error); + expect(err).toBeInstanceOf(AmbientWeatherRateLimitError); + expect((err as AmbientWeatherRateLimitError).retryAfter).toBeGreaterThan(0); + }); }); describe('ambientweather endpoints', () => { diff --git a/packages/ambientweather/client.ts b/packages/ambientweather/client.ts index 82777d20d..ad85011cf 100644 --- a/packages/ambientweather/client.ts +++ b/packages/ambientweather/client.ts @@ -91,6 +91,22 @@ function buildAmbientWeatherUrl( return url; } +/** Retry-After: delta-seconds or HTTP-date → non-negative delay ms. */ +function parseRetryAfterMs(header: string | null): number | undefined { + if (!header) return undefined; + + const seconds = Number(header); + if (Number.isFinite(seconds) && seconds >= 0) { + return seconds * 1000; + } + + const dateMs = Date.parse(header); + if (!Number.isFinite(dateMs)) return undefined; + + const delayMs = dateMs - Date.now(); + return delayMs >= 0 ? delayMs : undefined; +} + export async function makeAmbientWeatherRequest( endpoint: string, apiKey: string, @@ -129,14 +145,7 @@ export async function makeAmbientWeatherRequest( body = undefined; } - const retryAfterHeader = response.headers.get('Retry-After'); - const retryAfterMs = retryAfterHeader - ? Number(retryAfterHeader) * 1000 - : undefined; - const retryAfter = - retryAfterMs !== undefined && Number.isFinite(retryAfterMs) - ? retryAfterMs - : undefined; + const retryAfter = parseRetryAfterMs(response.headers.get('Retry-After')); if (response.status === 429) { throw new AmbientWeatherRateLimitError(