Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ out
# Nuxt.js build / generate output
.nuxt
dist
.pack/
.output

# Gatsby files
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down
13 changes: 7 additions & 6 deletions SERVICE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

## Ответственность

Библиотека проверяет карточку подтверждения и создаёт команду решения. Она даёт одинаковую границу
Web и адаптерам мессенджеров.
Библиотека проверяет карточку подтверждения и общий виджет подключения. Она даёт одинаковую границу
Web и адаптерам мессенджеров. Для карточки подтверждения библиотека создаёт команду решения.

## Не входит в ответственность

Expand All @@ -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`.
2 changes: 1 addition & 1 deletion catalog-info.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions contracts/SOURCE.md
Original file line number Diff line number Diff line change
@@ -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 и версию в документации.
28 changes: 28 additions & 0 deletions contracts/connection-widget.schema.json
Original file line number Diff line number Diff line change
@@ -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://"
}
}
}
}
}
12 changes: 8 additions & 4 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,24 @@

```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]
SDK --> Command[Decision command]
Command --> Backend[Backend канала]
```

SDK состоит из трёх простых частей:
SDK состоит из четырёх простых частей:

- снимок опубликованной JSON Schema;
- generated TypeScript type;
- runtime-проверка и создание команды решения.
- runtime-проверка;
- создание команды решения только для карточки подтверждения.

Виджет подключения содержит текст и HTTPS-ссылку. SDK проверяет данные, но не открывает ссылку и не
хранит OAuth state. Конкретный renderer решает, как показать кнопку.

Renderer и сеть находятся снаружи. Благодаря этому библиотека работает в браузере, Node.js и
будущем мобильном адаптере без зависимости от одного UI framework.
4 changes: 2 additions & 2 deletions docs/index.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Widget SDK

Widget SDK даёт каналам одну проверяемую карточку подтверждения. Он нужен, чтобы Web, Telegram и VK
одинаково понимали `actionId`, `payloadHash` и решение пользователя.
Widget SDK даёт каналам проверяемые виджеты. Web, Telegram и VK одинаково понимают карточку
подтверждения и кнопку подключения внешнего аккаунта.

Начните с [архитектуры](architecture.md), затем откройте [разработку](development.md).
11 changes: 6 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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": {
Expand Down
8 changes: 4 additions & 4 deletions scripts/check-docs.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions scripts/clean.mjs
Original file line number Diff line number Diff line change
@@ -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 });
7 changes: 3 additions & 4 deletions src/card-error.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
19 changes: 2 additions & 17 deletions src/card.ts
Original file line number Diff line number Diff line change
@@ -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<ActionConfirmationWidget>(cardSchema);
import { makeParser } from './schema-check.js';

export type Decision = 'CONFIRM' | 'CANCEL';

Expand All @@ -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<ActionConfirmationWidget>(cardSchema, (issues) => new CardError(issues));

export function makeDecision(card: ActionConfirmationWidget, decision: Decision): DecisionCommand {
const buttonId = decision === 'CONFIRM' ? 'confirm' : 'cancel';
Expand Down
9 changes: 9 additions & 0 deletions src/connection.ts
Original file line number Diff line number Diff line change
@@ -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<ExternalAccountConnectionWidget>(
connectionSchema,
(issues) => new WidgetError(issues),
);
18 changes: 18 additions & 0 deletions src/generated/connection-widget.ts
Original file line number Diff line number Diff line change
@@ -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;
};
}
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
24 changes: 24 additions & 0 deletions src/schema-check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import Ajv2020 from 'ajv/dist/2020.js';
import addFormats from 'ajv-formats';
import type { AnySchema } from 'ajv';

export function makeParser<T>(
schema: AnySchema,
makeError: (issues: readonly string[]) => Error,
): (value: unknown) => T {
const ajv = new Ajv2020({ allErrors: true });
addFormats(ajv);
const check = ajv.compile<T>(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);
};
}
9 changes: 9 additions & 0 deletions src/widget-error.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
34 changes: 34 additions & 0 deletions tests/connection.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
Loading