Skip to content
← Public packages

@kody/doordash-driver

Request DoorDash Drive (Dasher) deliveries with JWT auth from Developer Portal access keys.

src/core.ts

532 lines · 15.9 KB · TypeScript
/**
 * Shared DoorDash Drive transport: JWT (HS256 + dd-ver) from Developer Portal
 * access-key fields, dry-run mutations, setup-aware errors.
 *
 * Auth model:
 * - `developerId` + `keyId` live in package storage via `./settings` (Portal UUIDs).
 * - `doorDashSigningSecret` is a Kody secret (Portal `signing_secret`, base64).
 * - Live JWTs mint through `kody.secretJwtSign` with algorithm HS256 when the
 *   platform supports it, or via `createDriveJwt` when raw `signingSecret` is
 *   passed (local/tools only — never paste the signing secret in chat).
 *
 * Sandbox and production share openapi.doordash.com; the access key environment
 * decides which fleet you hit.
 */

import { kody, packageStorage } from 'kody:runtime'

export const API_BASE_URL = 'https://openapi.doordash.com'
export const API_HOST = 'openapi.doordash.com'
export const DEVELOPER_PORTAL_URL = 'https://developer.doordash.com/portal/auth/credentials'
export const DRIVE_DOCS_URL = 'https://developer.doordash.com/en-US/docs/drive/how_to/JWTs/'

export const DEFAULT_SIGNING_SECRET = 'doorDashSigningSecret'
export const SETTINGS_DEVELOPER_ID = 'developerId'
export const SETTINGS_KEY_ID = 'keyId'

/** Max JWT lifetime DoorDash allows (seconds). Keep tokens shorter in practice. */
export const JWT_MAX_TTL_SECONDS = 1800
export const JWT_DEFAULT_TTL_SECONDS = 300

const SIGNING_SECRET_PATTERN =
	/^doorDashSigningSecret(?:-[A-Za-z0-9][A-Za-z0-9_-]{0,47})?$/
const ACCOUNT_LABEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/

export type DoorDashAuthInput = {
	apiBaseUrl?: string
	/** Extra app label. `work` reads `doorDashSigningSecret-work`. */
	account?: string
	signingSecretSecret?: string
	/** Portal developer_id UUID. Prefer ./settings; override per call if needed. */
	developerId?: string
	/** Portal key_id UUID. Prefer ./settings; override per call if needed. */
	keyId?: string
	/**
	 * Raw Portal signing_secret (base64). Prefer the Kody secret
	 * `doorDashSigningSecret` — only pass this in trusted local tooling, never chat.
	 */
	signingSecret?: string
	/** Pre-minted Drive JWT. Skips minting when set. */
	jwt?: string
	/** JWT lifetime in seconds (1–1800). Default 300. */
	jwtTtlSeconds?: number
}

export type DoorDashObject = Record<string, any>

export type DoorDashRateLimitInfo = {
	retryAfter: number | null
	limit: string | null
	remaining: string | null
}

export class DoorDashApiError extends Error {
	status: number
	code: string | null
	details: unknown
	headers: Record<string, string>
	rateLimit: DoorDashRateLimitInfo
	setup: {
		signingSecretUrl: string
		hosts: string[]
		portal: string
		settings: string
	}

	constructor(
		message: string,
		input: {
			status: number
			code?: string | null
			details?: unknown
			headers?: Record<string, string>
			auth?: DoorDashAuthInput
		},
	) {
		super(message)
		this.name = 'DoorDashApiError'
		this.status = input.status
		this.code = input.code ?? null
		this.details = input.details ?? null
		this.headers = input.headers ?? {}
		this.rateLimit = parseRateLimitInfo(this.headers)
		this.setup = {
			signingSecretUrl: signingSecretSetupUrl(resolveSigningSecretName(input.auth)),
			hosts: [API_HOST],
			portal: DEVELOPER_PORTAL_URL,
			settings: 'kody:@kody/doordash-driver/settings',
		}
	}
}

export function assertNever(value: never, message: string): never {
	throw new Error(message + String(value))
}

export function parseAction<T extends string>(
	value: unknown,
	allowed: readonly T[],
	fallback: T,
	label: string,
): T {
	const action = (value == null || value === '' ? fallback : value) as unknown
	if (typeof action === 'string' && (allowed as readonly string[]).includes(action)) {
		return action as T
	}
	throw new Error(
		'Unknown ' + label + ' action: ' + String(action) + '. Valid actions: ' + allowed.join(', '),
	)
}

function accountLabel(value: string | undefined): string | null {
	const trimmed = (value ?? '').trim()
	if (!trimmed || trimmed === 'default') return null
	if (!ACCOUNT_LABEL_PATTERN.test(trimmed)) {
		throw new Error(
			'account must be a short label such as "work" or "sandbox" (letters, numbers, _ or -).',
		)
	}
	return trimmed
}

function suffixName(base: string, account: string | null): string {
	return account ? base + '-' + account : base
}

function assertSecretName(name: string, pattern: RegExp, expected: string): string {
	if (!pattern.test(name)) {
		throw new Error(
			'Secret name must match ' +
				expected +
				' (optional -label). Got: ' +
				JSON.stringify(name),
		)
	}
	return name
}

/** Resolve the user-scoped signing-secret secret name for this call. */
export function resolveSigningSecretName(input?: DoorDashAuthInput): string {
	if (input?.signingSecretSecret != null && String(input.signingSecretSecret).trim() !== '') {
		return assertSecretName(
			String(input.signingSecretSecret).trim(),
			SIGNING_SECRET_PATTERN,
			DEFAULT_SIGNING_SECRET,
		)
	}
	return suffixName(DEFAULT_SIGNING_SECRET, accountLabel(input?.account))
}

export function encodeQuery(params: Record<string, string | number | boolean | undefined | null>): string {
	const parts: string[] = []
	for (const [key, value] of Object.entries(params)) {
		if (value == null || value === '') continue
		parts.push(encodeURIComponent(key) + '=' + encodeURIComponent(String(value)))
	}
	return parts.length ? '?' + parts.join('&') : ''
}

function secretsNewUrl(name: string, description: string): string {
	const q =
		'name=' +
		encodeURIComponent(name) +
		'&description=' +
		encodeURIComponent(description) +
		'&allowedHosts=' +
		encodeURIComponent(API_HOST) +
		'&scope=user'
	return 'https://kody.codes/account/secrets/new?' + q
}

export function signingSecretSetupUrl(name = DEFAULT_SIGNING_SECRET): string {
	return secretsNewUrl(
		name,
		'DoorDash Drive signing_secret (base64) from developer.doordash.com Portal access key JSON',
	)
}

export function setupUrls(auth?: DoorDashAuthInput) {
	return {
		signingSecretUrl: signingSecretSetupUrl(resolveSigningSecretName(auth)),
		hosts: [API_HOST] as string[],
		portal: DEVELOPER_PORTAL_URL,
		docs: DRIVE_DOCS_URL,
		settings: 'import settings from "kody:@kody/doordash-driver/settings"',
	}
}

function base64UrlEncode(bytes: Uint8Array): string {
	let binary = ''
	for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!)
	return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
}

function base64UrlEncodeJson(value: unknown): string {
	return base64UrlEncode(new TextEncoder().encode(JSON.stringify(value)))
}

function decodeBase64ToBytes(value: string): Uint8Array {
	const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
	const pad = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4))
	const binary = atob(normalized + pad)
	const out = new Uint8Array(binary.length)
	for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i)
	return out
}

function normalizeTtl(ttlSeconds?: number): number {
	let ttl = ttlSeconds ?? JWT_DEFAULT_TTL_SECONDS
	if (!Number.isFinite(ttl) || ttl < 1 || ttl > JWT_MAX_TTL_SECONDS) {
		throw new Error('jwtTtlSeconds must be between 1 and ' + JWT_MAX_TTL_SECONDS + '.')
	}
	return Math.floor(ttl)
}

/**
 * Build a DoorDash Drive JWT (HS256, dd-ver DD-JWT-V1) from raw Portal fields.
 * Prefer secret-backed minting via `resolveDriveBearerToken` in package code.
 */
export async function createDriveJwt(input: {
	developerId: string
	keyId: string
	signingSecret: string
	ttlSeconds?: number
}): Promise<string> {
	const developerId = String(input.developerId ?? '').trim()
	const keyId = String(input.keyId ?? '').trim()
	const signingSecret = String(input.signingSecret ?? '').trim()
	if (!developerId || !keyId || !signingSecret) {
		throw new Error('developerId, keyId, and signingSecret are required to mint a Drive JWT.')
	}
	const ttl = normalizeTtl(input.ttlSeconds)

	const header = { alg: 'HS256', typ: 'JWT', 'dd-ver': 'DD-JWT-V1' }
	const now = Math.floor(Date.now() / 1000)
	const payload = {
		aud: 'doordash',
		iss: developerId,
		kid: keyId,
		iat: now,
		exp: now + ttl,
	}
	const signingInput = base64UrlEncodeJson(header) + '.' + base64UrlEncodeJson(payload)
	const key = await crypto.subtle.importKey(
		'raw',
		decodeBase64ToBytes(signingSecret),
		{ name: 'HMAC', hash: 'SHA-256' },
		false,
		['sign'],
	)
	const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signingInput))
	return signingInput + '.' + base64UrlEncode(new Uint8Array(sig))
}

export async function readDriveIdentity(auth?: DoorDashAuthInput): Promise<{
	developerId: string
	keyId: string
	source: { developerId: string; keyId: string }
}> {
	const fromAuthDev = String(auth?.developerId ?? '').trim()
	const fromAuthKey = String(auth?.keyId ?? '').trim()
	const storage = packageStorage()
	const storedDev = String((await storage.get(SETTINGS_DEVELOPER_ID)) ?? '').trim()
	const storedKey = String((await storage.get(SETTINGS_KEY_ID)) ?? '').trim()
	const developerId = fromAuthDev || storedDev
	const keyId = fromAuthKey || storedKey
	if (!developerId || !keyId) {
		throw new Error(
			'Save Portal developer_id and key_id via import settings from "kody:@kody/doordash-driver/settings" ' +
				'({ developerId, keyId }), or pass them on the call. Portal: ' +
				DEVELOPER_PORTAL_URL,
		)
	}
	return {
		developerId,
		keyId,
		source: {
			developerId: fromAuthDev ? 'call' : 'package-storage',
			keyId: fromAuthKey ? 'call' : 'package-storage',
		},
	}
}

/**
 * Mint (or reuse) a Drive Bearer token without exposing the signing secret.
 * Order: explicit `jwt` → raw `signingSecret` mint → `kody.secretJwtSign` HS256.
 */
export async function resolveDriveBearerToken(auth?: DoorDashAuthInput): Promise<{
	token: string
	via: 'jwt' | 'raw-signing-secret' | 'secretJwtSign'
	signingSecretSecret: string
	ttlSeconds: number
	developerId: string
	keyId: string
}> {
	const signingSecretSecret = resolveSigningSecretName(auth)
	const ttlSeconds = normalizeTtl(auth?.jwtTtlSeconds)
	const explicitJwt = String(auth?.jwt ?? '').trim()
	if (explicitJwt) {
		const { developerId, keyId } = await readDriveIdentity(auth).catch(() => ({
			developerId: '',
			keyId: '',
		}))
		return {
			token: explicitJwt,
			via: 'jwt',
			signingSecretSecret,
			ttlSeconds,
			developerId,
			keyId,
		}
	}

	const { developerId, keyId } = await readDriveIdentity(auth)
	const rawSigning = String(auth?.signingSecret ?? '').trim()
	if (rawSigning) {
		const token = await createDriveJwt({
			developerId,
			keyId,
			signingSecret: rawSigning,
			ttlSeconds,
		})
		return {
			token,
			via: 'raw-signing-secret',
			signingSecretSecret,
			ttlSeconds,
			developerId,
			keyId,
		}
	}

	const now = Math.floor(Date.now() / 1000)
	try {
		const signed = await kody.secretJwtSign({
			private_key_secret_name: signingSecretSecret,
			algorithm: 'HS256',
			key_encoding: 'base64',
			header: { 'dd-ver': 'DD-JWT-V1' },
			claims: {
				aud: 'doordash',
				iss: developerId,
				kid: keyId,
				iat: now,
				exp: now + ttlSeconds,
			},
		})
		const token = String((signed as { jwt?: string })?.jwt ?? '').trim()
		if (!token) {
			throw new Error('secretJwtSign returned no jwt')
		}
		return {
			token,
			via: 'secretJwtSign',
			signingSecretSecret,
			ttlSeconds,
			developerId,
			keyId,
		}
	} catch (error) {
		const message = error instanceof Error ? error.message : String(error)
		throw new Error(
			'Could not mint a DoorDash Drive JWT with secret ' +
				signingSecretSecret +
				'. Save the Portal signing_secret (base64) at ' +
				signingSecretSetupUrl(signingSecretSecret) +
				' and approve host ' +
				API_HOST +
				'. Drive JWTs use HS256; the platform secretJwtSign path must allow HS256 with base64 key material. ' +
				'Detail: ' +
				message,
		)
	}
}

export function parseRateLimitInfo(headers: Record<string, string>): DoorDashRateLimitInfo {
	const lower: Record<string, string> = {}
	for (const [k, v] of Object.entries(headers)) lower[k.toLowerCase()] = v
	const retryRaw = lower['retry-after']
	const retryAfter =
		retryRaw != null && retryRaw !== '' && Number.isFinite(Number(retryRaw))
			? Number(retryRaw)
			: null
	return {
		retryAfter,
		limit: lower['x-ratelimit-limit'] ?? lower['ratelimit-limit'] ?? null,
		remaining: lower['x-ratelimit-remaining'] ?? lower['ratelimit-remaining'] ?? null,
	}
}

function headersToObject(headers: Headers): Record<string, string> {
	const out: Record<string, string> = {}
	headers.forEach((value, key) => {
		out[key] = value
	})
	return out
}

export type DoorDashRequestResult<T = DoorDashObject> = {
	ok: true
	status: number
	data: T
	headers: Record<string, string>
	rateLimit: DoorDashRateLimitInfo
}

export type DoorDashRequestInput = DoorDashAuthInput & {
	method?: string
	path: string
	query?: Record<string, string | number | boolean | undefined | null>
	body?: unknown
	/** When true, return the preview without calling DoorDash. */
	dryRun?: boolean
	/**
	 * Mutations (POST/PUT/PATCH/DELETE) default to dry-run. Pass confirm: true
	 * to execute live. GET/HEAD never require confirm.
	 */
	confirm?: boolean
}

function isMutation(method: string): boolean {
	return method !== 'GET' && method !== 'HEAD'
}

/**
 * Signed request to DoorDash Drive (`openapi.doordash.com`).
 * Mutations are dry-run unless `confirm: true`.
 */
export async function doorDashRequest<T = DoorDashObject>(
	input: DoorDashRequestInput,
): Promise<DoorDashRequestResult<T> | { ok: true; dryRun: true; preview: DoorDashObject }> {
	const method = String(input.method ?? 'GET').toUpperCase()
	const path = String(input.path ?? '').trim()
	if (!path.startsWith('/')) {
		throw new Error('path must start with / (for example /drive/v2/deliveries).')
	}
	const base = String(input.apiBaseUrl ?? API_BASE_URL).replace(/\/+$/, '')
	const url = base + path + encodeQuery(input.query ?? {})

	const dryRunExplicit = input.dryRun === true
	const confirm = input.confirm === true
	const shouldDryRun =
		dryRunExplicit || (isMutation(method) && !confirm && input.dryRun !== false)

	const signingSecretSecret = resolveSigningSecretName(input)

	if (shouldDryRun) {
		let identity: { developerId: string; keyId: string } | null = null
		try {
			identity = await readDriveIdentity(input)
		} catch {
			identity = null
		}
		return {
			ok: true,
			dryRun: true,
			preview: {
				method,
				url,
				auth: 'DoorDash Drive JWT (HS256, dd-ver DD-JWT-V1)',
				signingSecretSecret,
				developerId: identity?.developerId ?? null,
				keyId: identity?.keyId ?? null,
				body: input.body ?? null,
				setup: setupUrls(input),
				note:
					isMutation(method) && !confirm
						? 'Mutation preview only. Pass confirm: true to call DoorDash.'
						: 'dryRun preview — no network call.',
			},
		}
	}

	const minted = await resolveDriveBearerToken(input)
	const headers: Record<string, string> = {
		Authorization: 'Bearer ' + minted.token,
		Accept: 'application/json',
	}
	let bodyText: string | undefined
	if (input.body !== undefined && input.body !== null && method !== 'GET' && method !== 'HEAD') {
		headers['Content-Type'] = 'application/json'
		bodyText = JSON.stringify(input.body)
	}

	const response = await fetch(url, { method, headers, body: bodyText })
	const responseHeaders = headersToObject(response.headers)
	const text = await response.text()
	let parsed: unknown = null
	if (text) {
		try {
			parsed = JSON.parse(text)
		} catch {
			parsed = { raw: text }
		}
	}

	if (!response.ok) {
		const obj = parsed && typeof parsed === 'object' ? (parsed as DoorDashObject) : null
		const code =
			(obj && (obj.code || obj.errorCode || obj.error_code || obj.type)) != null
				? String(obj.code || obj.errorCode || obj.error_code || obj.type)
				: null
		const message =
			(obj && (obj.message || obj.error || obj.detail)) != null
				? String(obj.message || obj.error || obj.detail)
				: 'DoorDash API error ' + response.status
		throw new DoorDashApiError(message, {
			status: response.status,
			code,
			details: parsed,
			headers: responseHeaders,
			auth: input,
		})
	}

	return {
		ok: true,
		status: response.status,
		data: (parsed ?? {}) as T,
		headers: responseHeaders,
		rateLimit: parseRateLimitInfo(responseHeaders),
	}
}