diff --git a/.gitignore b/.gitignore index 872d5f6..ada38f3 100644 --- a/.gitignore +++ b/.gitignore @@ -81,6 +81,7 @@ out # Nuxt.js build / generate output .nuxt dist +.pack/ .output # Gatsby files diff --git a/README.md b/README.md index 4eb464f..2422c3a 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,10 @@ SDK не рисует конкретный UI. Он проверяет неиз ## Что уже работает -- JSON Schema закреплена на release `portable-agent/contracts v2.3.0`; +- JSON Schema обновлены до `portable-agent/contracts v4.0.0`; - TypeScript type создаётся из схемы, а не пишется вручную; - `parseCard` отклоняет неизвестные поля и неверный hash; +- `parseConnection` проверяет общий виджет подключения и HTTPS-ссылку; - `makeDecision` создаёт `CONFIRM` или `CANCEL` только для доступной кнопки; - тесты, lint, typecheck, package build и документация проверяются в CI. @@ -27,6 +28,13 @@ const command = makeDecision(card, 'CONFIRM'); SDK не хранит token и не вызывает Action Service напрямую. Это делает backend-адаптер канала. +```typescript +import { parseConnection } from '@portable-agent/widget-sdk'; + +const connection = parseConnection(message.reply.card); +renderer.showLink(connection.button.label, connection.button.url); +``` + ## Проверка ```bash diff --git a/SERVICE.md b/SERVICE.md index c0ce549..afb22e0 100644 --- a/SERVICE.md +++ b/SERVICE.md @@ -2,8 +2,8 @@ ## Ответственность -Библиотека проверяет карточку подтверждения и создаёт команду решения. Она даёт одинаковую границу -Web и адаптерам мессенджеров. +Библиотека проверяет карточку подтверждения и общий виджет подключения. Она даёт одинаковую границу +Web и адаптерам мессенджеров. Для карточки подтверждения библиотека создаёт команду решения. ## Не входит в ответственность @@ -15,11 +15,12 @@ Web и адаптерам мессенджеров. ## Вход и выход -Вход — неизвестный JSON по `action-confirmation.schema.json`. Выход — типизированная карточка или -`CardError`. `makeDecision` возвращает `actionId`, `payloadHash` и `CONFIRM/CANCEL`, но ничего не -отправляет по сети. SDK не вызывает Action Service напрямую. +Вход — неизвестный JSON по одной из опубликованных JSON Schema. `parseCard` возвращает карточку +подтверждения или `CardError`. `parseConnection` возвращает виджет подключения или `WidgetError`. +`makeDecision` возвращает `actionId`, `payloadHash` и `CONFIRM/CANCEL`, но ничего не отправляет по сети. +SDK не вызывает Action Service напрямую и не открывает OAuth URL. ## Контракт -Текущий снимок: `portable-agent/contracts v2.3.0`. Источник и правило обновления записаны в +Текущий снимок: `portable-agent/contracts v4.0.0`. Источник и правило обновления записаны в `contracts/SOURCE.md`. diff --git a/catalog-info.yaml b/catalog-info.yaml index 77920de..5def015 100644 --- a/catalog-info.yaml +++ b/catalog-info.yaml @@ -6,7 +6,7 @@ metadata: annotations: github.com/project-slug: portable-agent/widget-sdk backstage.io/techdocs-ref: dir:. - portable-agent.dev/api-contract: action-confirmation@2.3.0 + portable-agent.dev/api-contract: widgets@4.0.0 spec: type: library lifecycle: experimental diff --git a/contracts/SOURCE.md b/contracts/SOURCE.md index bf8e81c..b78a825 100644 --- a/contracts/SOURCE.md +++ b/contracts/SOURCE.md @@ -1,8 +1,8 @@ # Источник контракта - Репозиторий: `portable-agent/contracts` -- Release: `v2.3.0` -- Файл: `schemas/action-confirmation.schema.json` +- Target release: `v4.0.0` from contracts PR #21 +- Файлы: `schemas/action-confirmation.schema.json`, `schemas/connection-widget.schema.json` Снимок нельзя менять вручную. Новая схема сначала выпускается в `contracts`, затем отдельный pull request обновляет этот файл, generated type и версию в документации. diff --git a/contracts/connection-widget.schema.json b/contracts/connection-widget.schema.json new file mode 100644 index 0000000..b02d5d5 --- /dev/null +++ b/contracts/connection-widget.schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://portable-agent.dev/contracts/widgets/connection-widget.schema.json", + "title": "External account connection widget", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "widget", "provider", "title", "text", "button"], + "properties": { + "schemaVersion": { "const": 1 }, + "widget": { "const": "connection" }, + "provider": { "enum": ["google-calendar"] }, + "title": { "type": "string", "minLength": 1, "maxLength": 120 }, + "text": { "type": "string", "minLength": 1, "maxLength": 1000 }, + "button": { + "type": "object", + "additionalProperties": false, + "required": ["label", "url"], + "properties": { + "label": { "type": "string", "minLength": 1, "maxLength": 80 }, + "url": { + "type": "string", + "format": "uri", + "pattern": "^https://" + } + } + } + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 84c5a3f..eeccf09 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,8 +2,8 @@ ```mermaid flowchart LR - Conversation[Conversation Service] --> Card[JSON карточка] - Card --> SDK[Widget SDK] + Conversation[Conversation Service] --> Widget[JSON widget] + Widget --> SDK[Widget SDK] SDK --> Web[Web renderer] SDK --> TG[Telegram adapter] SDK --> VK[VK adapter] @@ -11,11 +11,15 @@ flowchart LR Command --> Backend[Backend канала] ``` -SDK состоит из трёх простых частей: +SDK состоит из четырёх простых частей: - снимок опубликованной JSON Schema; - generated TypeScript type; -- runtime-проверка и создание команды решения. +- runtime-проверка; +- создание команды решения только для карточки подтверждения. + +Виджет подключения содержит текст и HTTPS-ссылку. SDK проверяет данные, но не открывает ссылку и не +хранит OAuth state. Конкретный renderer решает, как показать кнопку. Renderer и сеть находятся снаружи. Благодаря этому библиотека работает в браузере, Node.js и будущем мобильном адаптере без зависимости от одного UI framework. diff --git a/docs/index.md b/docs/index.md index 01959d3..0d9ac90 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,6 +1,6 @@ # Widget SDK -Widget SDK даёт каналам одну проверяемую карточку подтверждения. Он нужен, чтобы Web, Telegram и VK -одинаково понимали `actionId`, `payloadHash` и решение пользователя. +Widget SDK даёт каналам проверяемые виджеты. Web, Telegram и VK одинаково понимают карточку +подтверждения и кнопку подключения внешнего аккаунта. Начните с [архитектуры](architecture.md), затем откройте [разработку](development.md). diff --git a/package.json b/package.json index b44a11d..014ea74 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@portable-agent/widget-sdk", - "version": "0.1.0", + "version": "0.2.0", "description": "Проверка карточек Portable Agent для Web и мессенджеров", "type": "module", "license": "Apache-2.0", @@ -23,14 +23,15 @@ } }, "scripts": { - "generate": "json2ts -i contracts/action-confirmation.schema.json -o src/generated/action-confirmation.ts && prettier --write src/generated/action-confirmation.ts", - "check:generated": "pnpm generate && git diff --exit-code -- src/generated/action-confirmation.ts", + "generate": "json2ts -i contracts/action-confirmation.schema.json -o src/generated/action-confirmation.ts && json2ts -i contracts/connection-widget.schema.json -o src/generated/connection-widget.ts && prettier --write src/generated/*.ts", + "clean": "node scripts/clean.mjs", + "check:generated": "pnpm generate && git diff --exit-code -- src/generated", "lint": "pnpm check:generated && eslint . && prettier --check .", "format": "prettier --write .", "test": "pnpm generate && vitest run --coverage", "typecheck": "pnpm generate && tsc --noEmit -p tsconfig.test.json", - "build": "pnpm generate && tsc -p tsconfig.json", - "pack:check": "pnpm build && pnpm pack --pack-destination dist/package", + "build": "pnpm clean && pnpm generate && tsc -p tsconfig.json", + "pack:check": "pnpm build && pnpm pack --pack-destination .pack", "prepack": "pnpm build" }, "dependencies": { diff --git a/scripts/check-docs.ps1 b/scripts/check-docs.ps1 index 0d53b8e..ed36c92 100644 --- a/scripts/check-docs.ps1 +++ b/scripts/check-docs.ps1 @@ -23,13 +23,13 @@ $catalogText = Get-Content -LiteralPath "catalog-info.yaml" -Raw if ($catalogText -notmatch "backstage\.io/techdocs-ref:\s*dir:\.") { throw "В catalog-info.yaml нет backstage.io/techdocs-ref: dir:." } -if ($catalogText -notmatch "action-confirmation@2\.3\.0") { - throw "В catalog-info.yaml должна быть закреплена версия контракта 2.3.0." +if ($catalogText -notmatch "widgets@4\.0\.0") { + throw "В catalog-info.yaml должна быть закреплена версия контрактов 4.0.0." } $sourceText = Get-Content -LiteralPath "contracts/SOURCE.md" -Raw -if ($sourceText -notmatch "v2\.3\.0") { - throw "contracts/SOURCE.md не содержит release v2.3.0." +if ($sourceText -notmatch "v4\.0\.0") { + throw "contracts/SOURCE.md не содержит target release v4.0.0." } $serviceText = Get-Content -LiteralPath "SERVICE.md" -Raw diff --git a/scripts/clean.mjs b/scripts/clean.mjs new file mode 100644 index 0000000..b405dc9 --- /dev/null +++ b/scripts/clean.mjs @@ -0,0 +1,4 @@ +import { rm } from 'node:fs/promises'; +import { URL } from 'node:url'; + +await rm(new URL('../dist/', import.meta.url), { recursive: true, force: true }); diff --git a/src/card-error.ts b/src/card-error.ts index be9357f..c421ce1 100644 --- a/src/card-error.ts +++ b/src/card-error.ts @@ -1,9 +1,8 @@ -export class CardError extends Error { - readonly issues: readonly string[]; +import { WidgetError } from './widget-error.js'; +export class CardError extends WidgetError { constructor(issues: readonly string[]) { - super('Card does not match the public contract'); + super(issues); this.name = 'CardError'; - this.issues = issues; } } diff --git a/src/card.ts b/src/card.ts index 83ea6e5..dd976b9 100644 --- a/src/card.ts +++ b/src/card.ts @@ -1,12 +1,7 @@ -import Ajv2020 from 'ajv/dist/2020.js'; -import addFormats from 'ajv-formats'; import cardSchema from '../contracts/action-confirmation.schema.json' with { type: 'json' }; import { CardError } from './card-error.js'; import type { ActionConfirmationWidget } from './generated/action-confirmation.js'; - -const ajv = new Ajv2020({ allErrors: true }); -addFormats(ajv); -const checkCard = ajv.compile(cardSchema); +import { makeParser } from './schema-check.js'; export type Decision = 'CONFIRM' | 'CANCEL'; @@ -16,17 +11,7 @@ export interface DecisionCommand { payloadHash: string; } -export function parseCard(value: unknown): ActionConfirmationWidget { - if (checkCard(value)) { - return value; - } - - const issues = (checkCard.errors ?? []).map((error) => { - const path = error.instancePath === '' ? '/' : error.instancePath; - return `${path}: ${error.message}`; - }); - throw new CardError(issues); -} +export const parseCard = makeParser(cardSchema, (issues) => new CardError(issues)); export function makeDecision(card: ActionConfirmationWidget, decision: Decision): DecisionCommand { const buttonId = decision === 'CONFIRM' ? 'confirm' : 'cancel'; diff --git a/src/connection.ts b/src/connection.ts new file mode 100644 index 0000000..67d7696 --- /dev/null +++ b/src/connection.ts @@ -0,0 +1,9 @@ +import connectionSchema from '../contracts/connection-widget.schema.json' with { type: 'json' }; +import type { ExternalAccountConnectionWidget } from './generated/connection-widget.js'; +import { makeParser } from './schema-check.js'; +import { WidgetError } from './widget-error.js'; + +export const parseConnection = makeParser( + connectionSchema, + (issues) => new WidgetError(issues), +); diff --git a/src/generated/connection-widget.ts b/src/generated/connection-widget.ts new file mode 100644 index 0000000..a85774a --- /dev/null +++ b/src/generated/connection-widget.ts @@ -0,0 +1,18 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +export interface ExternalAccountConnectionWidget { + schemaVersion: 1; + widget: 'connection'; + provider: 'google-calendar'; + title: string; + text: string; + button: { + label: string; + url: string; + }; +} diff --git a/src/index.ts b/src/index.ts index 949712d..4e67b0d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,7 @@ export { CardError } from './card-error.js'; +export { WidgetError } from './widget-error.js'; export { makeDecision, parseCard } from './card.js'; +export { parseConnection } from './connection.js'; export type { Decision, DecisionCommand } from './card.js'; export type { ActionConfirmationWidget } from './generated/action-confirmation.js'; +export type { ExternalAccountConnectionWidget } from './generated/connection-widget.js'; diff --git a/src/schema-check.ts b/src/schema-check.ts new file mode 100644 index 0000000..741d2d6 --- /dev/null +++ b/src/schema-check.ts @@ -0,0 +1,24 @@ +import Ajv2020 from 'ajv/dist/2020.js'; +import addFormats from 'ajv-formats'; +import type { AnySchema } from 'ajv'; + +export function makeParser( + schema: AnySchema, + makeError: (issues: readonly string[]) => Error, +): (value: unknown) => T { + const ajv = new Ajv2020({ allErrors: true }); + addFormats(ajv); + const check = ajv.compile(schema); + + return (value: unknown): T => { + if (check(value)) { + return value as T; + } + + const issues = (check.errors ?? []).map((error) => { + const path = error.instancePath === '' ? '/' : error.instancePath; + return `${path}: ${error.message}`; + }); + throw makeError(issues); + }; +} diff --git a/src/widget-error.ts b/src/widget-error.ts new file mode 100644 index 0000000..c5af2c8 --- /dev/null +++ b/src/widget-error.ts @@ -0,0 +1,9 @@ +export class WidgetError extends Error { + readonly issues: readonly string[]; + + constructor(issues: readonly string[]) { + super('Widget does not match the public contract'); + this.name = 'WidgetError'; + this.issues = issues; + } +} diff --git a/tests/connection.test.ts b/tests/connection.test.ts new file mode 100644 index 0000000..9201e13 --- /dev/null +++ b/tests/connection.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { parseConnection, WidgetError } from '../src/index.js'; + +const validConnection = { + schemaVersion: 1, + widget: 'connection', + provider: 'google-calendar', + title: 'Подключить Google Calendar', + text: 'Подключите календарь и повторите команду.', + button: { + label: 'Подключить', + url: 'https://accounts.google.com/oauth', + }, +}; + +describe('parseConnection', () => { + it('returns a typed channel-neutral widget', () => { + expect(parseConnection(validConnection)).toEqual(validConnection); + }); + + it('rejects an unsafe URL', () => { + expect(() => + parseConnection({ + ...validConnection, + button: { ...validConnection.button, url: 'http://unsafe.example/oauth' }, + }), + ).toThrow(WidgetError); + }); + + it('does not expose private input in an error', () => { + expect(() => parseConnection({ ...validConnection, state: 'private-state' })).toThrow(WidgetError); + expect(() => parseConnection({ ...validConnection, state: 'private-state' })).not.toThrow(/private-state/); + }); +});