From a4e127a83162069299715ad0d7149978b2973564 Mon Sep 17 00:00:00 2001 From: zer0stars <74260741+zer0stars@users.noreply.github.com> Date: Sun, 30 Aug 2026 08:29:06 -0400 Subject: [PATCH 01/11] feat(templates): typed, server-only client for definitions-worker Types are generated from the worker's schema rather than hand-written a third time, and the vocabulary is fetched at runtime so the editor form cannot offer an option the validator rejects. A runtime guard makes the server-only rule a guarantee instead of a convention: DEFINITIONS_WRITE_TOKEN cannot reach a browser bundle without throwing. The guard is testable both ways -- definitions.test.ts runs under @jest-environment node, and definitionsServerOnly.test.ts asserts the module refuses to load under jsdom. --- README.md | 12 ++ __tests__/unit/services/definitions.test.ts | 112 +++++++++++++++++ .../services/definitionsServerOnly.test.ts | 11 ++ package-lock.json | 47 ++++++++ package.json | 2 + scripts/gen-template-types.mjs | 38 ++++++ src/config/default.ts | 4 + src/config/index.ts | 1 + src/config/preview.ts | 2 + src/config/production.ts | 2 + src/services/definitions.ts | 114 ++++++++++++++++++ src/types/generated/template.ts | 90 ++++++++++++++ src/types/template.ts | 38 ++++++ 13 files changed, 473 insertions(+) create mode 100644 __tests__/unit/services/definitions.test.ts create mode 100644 __tests__/unit/services/definitionsServerOnly.test.ts create mode 100644 scripts/gen-template-types.mjs create mode 100644 src/services/definitions.ts create mode 100644 src/types/generated/template.ts create mode 100644 src/types/template.ts diff --git a/README.md b/README.md index 92a88255..22a41cf6 100644 --- a/README.md +++ b/README.md @@ -91,10 +91,22 @@ RPC_URL= NEXT_PUBLIC_TURNKEY_API_BASE_URL="https://api.turnkey.com" NEXT_PUBLIC_RPID="localhost" NEXT_PUBLIC_GA_API= + +# Vehicle templates. Both are server-only -- no NEXT_PUBLIC_ prefix, ever. +DEFINITIONS_WRITE_TOKEN= +DIMO_CURATOR_ADDRESSES= ``` Make sure that the `NEXT_PUBLIC_GA_API` maps to your [Accounts API](https://github.com/DIMO-Network/accounts/tree/main) deployment URL. +`DEFINITIONS_WRITE_TOKEN` is the bearer token for `PUT /t/:id` on +[definitions-worker](https://github.com/DIMO-Network/definitions-worker). It is +read only by `src/services/definitions.ts`, which throws if it is ever evaluated +in a browser — the token must never reach a client bundle. +`DIMO_CURATOR_ADDRESSES` lists the addresses allowed to set +`hardwareTemplateId`, which decides what hardware ships and is never open to +contributors. + 3. Install the dependencies: ```bash diff --git a/__tests__/unit/services/definitions.test.ts b/__tests__/unit/services/definitions.test.ts new file mode 100644 index 00000000..6a920dc2 --- /dev/null +++ b/__tests__/unit/services/definitions.test.ts @@ -0,0 +1,112 @@ +/** + * @jest-environment node + * + * services/definitions is server-only and throws when `window` exists, which is + * the guarantee that DEFINITIONS_WRITE_TOKEN cannot reach a browser bundle. The + * default jsdom environment therefore cannot load it at all -- see the + * "refuses to load in a browser" case below, which asserts exactly that. + */ +import { fetchTemplate, fetchVocabulary, publishTemplate } from '@/services/definitions'; +import type { TemplatePayload } from '@/types/template'; + +const payload = { + id: 'toyota_camry_2020', + deviceType: 'vehicle', + manufacturer: { slug: 'toyota', name: 'Toyota' }, + model: 'Camry', + year: 2020, + attributes: {}, + trims: [{ name: 'LE', attributes: {} }], +} as unknown as TemplatePayload; + +const mockFetch = (impl: jest.Mock) => { + global.fetch = impl as unknown as typeof fetch; + return impl; +}; + +describe('definitions service', () => { + it('returns null for a template that does not exist yet', async () => { + mockFetch( + jest.fn().mockResolvedValue({ ok: false, status: 404, json: async () => ({}) }), + ); + expect(await fetchTemplate('ineos_grenadier_2024')).toBeNull(); + }); + + it('sends If-None-Match on create and If-Match on update, and never the token to the body', async () => { + process.env.DEFINITIONS_WRITE_TOKEN = 'secret-token'; + const f = mockFetch( + jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ ...payload, version: 1 }), + }), + ); + + await publishTemplate('toyota_camry_2020', payload, { kind: 'create' }); + expect(f.mock.calls[0][1].headers['If-None-Match']).toBe('*'); + expect(f.mock.calls[0][1].headers.Authorization).toBe('Bearer secret-token'); + expect(f.mock.calls[0][1].body).not.toContain('secret-token'); + + await publishTemplate('toyota_camry_2020', payload, { kind: 'update', version: 7 }); + expect(f.mock.calls[1][1].headers['If-Match']).toBe('"7"'); + }); + + it('maps 422 to validation errors and 412 to a conflict', async () => { + mockFetch( + jest.fn().mockResolvedValue({ + ok: false, + status: 422, + json: async () => ({ errors: ['template: unknown attribute "nope"'] }), + }), + ); + expect( + await publishTemplate('toyota_camry_2020', payload, { kind: 'create' }), + ).toEqual({ + ok: false, + kind: 'validation', + errors: ['template: unknown attribute "nope"'], + }); + + mockFetch( + jest.fn().mockResolvedValue({ + ok: false, + status: 412, + json: async () => ({ + error: 'expected version 6 but the current version is 7', + expected: 6, + actual: 7, + }), + }), + ); + expect( + await publishTemplate('toyota_camry_2020', payload, { kind: 'update', version: 6 }), + ).toEqual({ ok: false, kind: 'conflict', expected: 6, actual: 7 }); + }); + + it('refuses a payload over the worker 64KB cap before spending a request', async () => { + const f = mockFetch(jest.fn()); + const fat = { + ...payload, + trims: Array.from({ length: 4000 }, (_, i) => ({ name: `T${i}`, attributes: {} })), + }; + const result = await publishTemplate( + 'toyota_camry_2020', + fat as unknown as TemplatePayload, + { kind: 'create' }, + ); + expect(result).toMatchObject({ ok: false, kind: 'too-large' }); + expect(f).not.toHaveBeenCalled(); + }); + + it('fetches the vocabulary from the worker, not from a vendored copy', async () => { + const f = mockFetch( + jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ id: 'vehicle', name: 'Vehicle', attributes: [] }), + }), + ); + await fetchVocabulary(); + expect(f.mock.calls[0][0]).toContain('/schema/device-type-vehicle.json'); + }); +}); diff --git a/__tests__/unit/services/definitionsServerOnly.test.ts b/__tests__/unit/services/definitionsServerOnly.test.ts new file mode 100644 index 00000000..bd9e6ea3 --- /dev/null +++ b/__tests__/unit/services/definitionsServerOnly.test.ts @@ -0,0 +1,11 @@ +/** + * Runs in jsdom (the project default) on purpose: importing the server-only + * module where `window` exists must fail. This is gate 1's guarantee, and a + * convention would not be testable. + */ +describe('services/definitions in a browser', () => { + it('refuses to load', async () => { + const { fetchTemplate } = await import('@/services/definitions'); + await expect(fetchTemplate('toyota_camry_2020')).rejects.toThrow(/server-only/); + }); +}); diff --git a/package-lock.json b/package-lock.json index ff49d886..5e412d25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -81,6 +81,7 @@ "husky": "^9.1.7", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", + "json-schema-to-typescript": "^15.0.4", "nock": "^13.5.6", "postcss": "^8.5.6", "postcss-nesting": "^12.1.5", @@ -117,6 +118,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "11.9.3", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz", + "integrity": "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==", + "dev": true, + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, "node_modules/@apollo/client": { "version": "3.14.0", "resolved": "https://registry.npmjs.org/@apollo/client/-/client-3.14.0.tgz", @@ -4282,6 +4300,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "dev": true + }, "node_modules/@lit-labs/ssr-dom-shim": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.4.0.tgz", @@ -22300,6 +22324,29 @@ "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", "license": "(AFL-2.1 OR BSD-3-Clause)" }, + "node_modules/json-schema-to-typescript": { + "version": "15.0.4", + "resolved": "https://registry.npmjs.org/json-schema-to-typescript/-/json-schema-to-typescript-15.0.4.tgz", + "integrity": "sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==", + "dev": true, + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^11.5.5", + "@types/json-schema": "^7.0.15", + "@types/lodash": "^4.17.7", + "is-glob": "^4.0.3", + "js-yaml": "^4.1.0", + "lodash": "^4.17.21", + "minimist": "^1.2.8", + "prettier": "^3.2.5", + "tinyglobby": "^0.2.9" + }, + "bin": { + "json2ts": "dist/src/cli.js" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", diff --git a/package.json b/package.json index 795b2168..f7bfe280 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "test:update-snap": "jest -u", "test:watch": "jest --watch", "compile": "graphql-codegen --config src/codegen.ts", + "gen:template-types": "node scripts/gen-template-types.mjs", "prepare": "husky" }, "dependencies": { @@ -91,6 +92,7 @@ "husky": "^9.1.7", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", + "json-schema-to-typescript": "^15.0.4", "nock": "^13.5.6", "postcss": "^8.5.6", "postcss-nesting": "^12.1.5", diff --git a/scripts/gen-template-types.mjs b/scripts/gen-template-types.mjs new file mode 100644 index 00000000..8342dda0 --- /dev/null +++ b/scripts/gen-template-types.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node +// The template contract is definitions-worker/schema/template.schema.json, +// served at /schema/. Console generates from it rather +// than hand-writing a third copy (the worker's TS and dd-api's Go are the +// other two). Run after the contract changes; the output is committed. +import { compile } from 'json-schema-to-typescript'; +import { writeFile, mkdir, readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +const OUT = new URL('../src/types/generated/template.ts', import.meta.url); +const BANNER = `/* eslint-disable */ +// GENERATED by scripts/gen-template-types.mjs from +// definitions-worker/schema/template.schema.json. Do not edit. +`; + +const fromFlag = process.argv.indexOf('--from'); +const source = + fromFlag !== -1 + ? process.argv[fromFlag + 1] + : `${process.env.DEFINITIONS_WORKER_URL ?? 'https://definitions.dev.dimo.org'}/schema/template.schema.json`; + +const schema = source.startsWith('http') + ? await (await fetch(source)).json() + : JSON.parse(await readFile(source, 'utf8')); + +const body = await compile(schema, 'Template', { + bannerComment: '', + additionalProperties: false, + // Mirrors definitions-worker/scripts/gen-types.mjs: minItems on trims + // otherwise generates [Trim, ...Trim[]], which no .map() result can be + // assigned back to. + ignoreMinAndMaxItems: true, + style: { singleQuote: true, semi: true, printWidth: 90 }, +}); + +await mkdir(new URL('../src/types/generated/', import.meta.url), { recursive: true }); +await writeFile(OUT, BANNER + body); +console.log(`wrote ${fileURLToPath(OUT)} from ${source}`); diff --git a/src/config/default.ts b/src/config/default.ts index 335af9cd..6095125d 100644 --- a/src/config/default.ts +++ b/src/config/default.ts @@ -44,6 +44,10 @@ export const frontendUrl = 'http://localhost:3000/'; export const identityApiUrl = 'https://identity-api.dev.dimo.zone/query'; +// definitions-worker: canonical template documents on R2, plus the schema +// documents the editor form is driven by. See definitions-worker/wrangler.toml. +export const definitionsWorkerUrl = 'https://definitions.dev.dimo.org'; + export const RAINBOW_PROJECT = { ID: '528803928611a7781fb6b23eaf232224', NAME: 'Dimo Developer Console', diff --git a/src/config/index.ts b/src/config/index.ts index b3843881..6b9ee58d 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -30,6 +30,7 @@ type Configuration = { ISSUED_TOPIC: `0x${string}`; CONTRACT_METHODS: Record; identityApiUrl: string; + definitionsWorkerUrl: string; DIMO_ESCROW_ADDRESS: `0x${string}`; DIMO_SACD_ADDRESS: `0x${string}`; DIMO_REGISTRY_ADDRESS: `0x${string}`; diff --git a/src/config/preview.ts b/src/config/preview.ts index 188c1ca9..b4d0983d 100644 --- a/src/config/preview.ts +++ b/src/config/preview.ts @@ -10,6 +10,8 @@ export enum CONTRACT_METHODS { export const identityApiUrl = 'https://identity-api.dev.dimo.zone/query'; +export const definitionsWorkerUrl = 'https://definitions.dev.dimo.org'; + export const DCC_ADDRESS = '0x41799E9Dc893722844E771a1C1cAf3BBc2876132'; // Connections // DIMO REGISTRY AMOY diff --git a/src/config/production.ts b/src/config/production.ts index d5893828..d1d5d447 100644 --- a/src/config/production.ts +++ b/src/config/production.ts @@ -31,6 +31,8 @@ export const CONTRACT_METHODS = { export const identityApiUrl = 'https://identity-api.dimo.zone/query'; +export const definitionsWorkerUrl = 'https://definitions.dimo.org'; + export const DIMO_ESCROW_ADDRESS = '0x69977330D192701071D9b370Ac849065A545dEA5'; export const DIMO_SACD_ADDRESS = '0x3c152B5d96769661008Ff404224d6530FCAC766d'; diff --git a/src/services/definitions.ts b/src/services/definitions.ts new file mode 100644 index 00000000..0f14119e --- /dev/null +++ b/src/services/definitions.ts @@ -0,0 +1,114 @@ +import config from '@/config'; +import type { DeviceType, Template, TemplatePayload } from '@/types/template'; + +// definitions-worker/src/index.ts MAX_DOC_BYTES. Checked here so an oversized +// draft is reported in the editor rather than as an opaque 413 after a +// round trip. +const MAX_DOC_BYTES = 64 * 1024; + +// Gate 1: WRITE_TOKEN must never reach a browser bundle. A convention is not +// a guarantee; this is. Any client component that imports this module fails +// loudly in development instead of shipping the token. +function assertServer(): void { + if (typeof window !== 'undefined') { + throw new Error( + 'services/definitions is server-only — it holds DEFINITIONS_WRITE_TOKEN', + ); + } +} + +export type Precondition = { kind: 'create' } | { kind: 'update'; version: number }; + +export type PublishResult = + | { ok: true; template: Template } + | { ok: false; kind: 'validation'; errors: string[] } + | { ok: false; kind: 'conflict'; expected: number | null; actual: number } + | { ok: false; kind: 'too-large'; bytes: number; limit: number } + | { ok: false; kind: 'upstream'; status: number; message: string }; + +export async function fetchTemplate(id: string): Promise