Skip to content

Commit 92dc36d

Browse files
committed
chore: check:contract / fix:contract — pin the embed-api.json contract against the live /embed/json manifest
check:contract (CI) fails whenever the committed pin differs from the manifest production serves (editor_version excluded); fix:contract re-syncs the pin (prettier-stable formatting) and regenerates src/generated/. Synced pin picks up bad_request:download_blocked on DOWNLOAD.
1 parent 637eba8 commit 92dc36d

5 files changed

Lines changed: 118 additions & 5 deletions

File tree

.github/workflows/embed.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,8 @@ jobs:
5151

5252
- name: API surface
5353
run: npm run --workspace @simplepdf/embed check:api
54+
55+
# Fails whenever the committed embed-api.json pin differs from the live
56+
# /embed/json manifest — re-sync the pin and `npm run generate` to fix.
57+
- name: Contract pin matches live manifest
58+
run: npm run --workspace @simplepdf/embed check:contract

embed/embed-api.json

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"editor_version": "40196b30-20260626T045356Z",
2+
"editor_version": "07807b8d-20260723T044102Z",
33
"description": "This is the SimplePDF editor interface contract. Drive the editor programmatically over window.postMessage with the editor iframe: post a JSON string { \"type\": {{operation request_type}}, \"request_id\": {{a request correlation id you generate}}, \"data\": {{input matching input_schema}} } to the iframe; the editor replies with { \"type\": \"REQUEST_RESULT\", \"data\": { \"request_id\": {{the same correlation id}}, \"result\": {{result}} } } (match each reply to its request by this id), where result is { \"success\": true, \"data\": {{a value matching the operation's output_schema; null for ops that return nothing}} } or { \"success\": false, \"error\": { code, message } }. Each operation lists the op-specific `error_codes` it can return; on top of those, any op may also fail with a gateway/permission code (origin/plan/signup gating, editor-not-ready, or an internal error). Every code is within `editor_error_schema` (the complete closed union to narrow against), where each code const carries a `description` of its meaning. Outbound events (see `events`) are pushed the same way. `operations` lists every operation the editor supports. Most require the embedding origin to be allowlisted (\"whitelisted\") for the tenant in the SimplePDF admin dashboard, and the tenant plan to permit them (LOAD_DOCUMENT is always available). A call the current setup does not permit returns the matching gateway code: forbidden:origin_not_whitelisted when the origin is not allowlisted, or bad_request:plan_upgrade_required when the plan excludes it. All JSON schemas use the json_schema_dialect declared at the root. We recommend the @simplepdf/embed package for a typed, ergonomic wrapper over this contract (https://github.com/SimplePDF/simplepdf-embed).",
44
"json_schema_dialect": "https://json-schema.org/draft/2020-12/schema",
55
"protocol": {
@@ -230,7 +230,11 @@
230230
"output_schema": {
231231
"type": "null"
232232
},
233-
"error_codes": ["bad_request:no_document_loaded", "bad_request:missing_required_fields"]
233+
"error_codes": [
234+
"bad_request:no_document_loaded",
235+
"bad_request:missing_required_fields",
236+
"bad_request:download_blocked"
237+
]
234238
},
235239
{
236240
"request_type": "FOCUS_FIELD",
@@ -635,6 +639,11 @@
635639
"properties": {
636640
"code": {
637641
"anyOf": [
642+
{
643+
"type": "string",
644+
"const": "bad_request:download_blocked",
645+
"description": "The browser blocked delivering the generated document (share sheet or new tab denied without a user gesture, or a popup blocker). Retry the download from a direct user interaction."
646+
},
638647
{
639648
"type": "string",
640649
"const": "bad_request:editor_not_ready",

embed/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,9 @@
6565
"test:watch": "vitest",
6666
"check:size": "npm run build && node scripts/check-bundle-size.mjs",
6767
"check:exports": "node ../scripts/check-exports.mjs .",
68-
"check:api": "node ../scripts/check-api.mjs ."
68+
"check:api": "node ../scripts/check-api.mjs .",
69+
"check:contract": "node scripts/embed-contract.mjs",
70+
"fix:contract": "node scripts/embed-contract.mjs --fix"
6971
},
7072
"peerDependencies": {
7173
"@tanstack/ai": "^0.38.0",

embed/scripts/embed-contract.mjs

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Contract-freshness guard + fixer. The committed embed-api.json is a PIN of the
2+
// manifest served at /embed/json; everything in src/generated/ derives from it. The pin
3+
// must strictly equal the live manifest (editor_version excluded — injected per deploy
4+
// at the serve boundary): a drifted pin means the SDK is generated from a contract the
5+
// editor no longer serves.
6+
//
7+
// check (default): `npm run check:contract` — fails when the pin is out of date.
8+
// fix: `npm run fix:contract` — re-syncs the pin from the live manifest
9+
// (prettier-formatted to keep diffs minimal) and regenerates
10+
// src/generated/; review the diff and commit both.
11+
12+
import { execFileSync } from 'node:child_process'
13+
import { readFileSync, writeFileSync } from 'node:fs'
14+
import { dirname, join } from 'node:path'
15+
import { fileURLToPath } from 'node:url'
16+
import { isDeepStrictEqual } from 'node:util'
17+
18+
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
19+
const PIN_PATH = join(PKG_ROOT, 'embed-api.json')
20+
21+
// Overridable so the pin can be checked / synced against a staging / local editor.
22+
const CONTRACT_URL = process.env.EMBED_CONTRACT_URL ?? 'https://simplepdf.com/embed/json'
23+
24+
const EXIT_CODES = {
25+
stale_pin: 1,
26+
manifest_fetch_failed: 2,
27+
}
28+
29+
const isFixMode = process.argv.includes('--fix')
30+
31+
const withoutEditorVersion = ({ editor_version: _editorVersion, ...manifest }) => manifest
32+
33+
const fetchLiveManifest = async () => {
34+
try {
35+
const manifestResponse = await fetch(CONTRACT_URL)
36+
if (!manifestResponse.ok) {
37+
throw new Error(`unexpected response status ${manifestResponse.status}`)
38+
}
39+
return await manifestResponse.json()
40+
} catch (error) {
41+
console.error(`contract: failed to fetch the live manifest from ${CONTRACT_URL}: ${error.message}`)
42+
process.exit(EXIT_CODES.manifest_fetch_failed)
43+
}
44+
}
45+
46+
const liveManifest = await fetchLiveManifest()
47+
48+
if (isFixMode) {
49+
writeFileSync(PIN_PATH, `${JSON.stringify(liveManifest, null, 2)}\n`)
50+
// The repo-pinned prettier, so the committed pin formatting stays byte-stable
51+
// across syncs and the next diff shows only real contract changes.
52+
execFileSync(join(PKG_ROOT, '..', 'node_modules', '.bin', 'prettier'), ['--write', PIN_PATH], { stdio: 'inherit' })
53+
execFileSync('node', [join(PKG_ROOT, 'scripts', 'generate.mjs')], { cwd: PKG_ROOT, stdio: 'inherit' })
54+
console.log(
55+
`fix:contract: pin re-synced to ${CONTRACT_URL} (editor_version: ${liveManifest.editor_version}) — review the diff and commit embed-api.json + src/generated/`,
56+
)
57+
process.exit(0)
58+
}
59+
60+
const pinnedManifest = withoutEditorVersion(JSON.parse(readFileSync(PIN_PATH, 'utf8')))
61+
const comparableLiveManifest = withoutEditorVersion(liveManifest)
62+
63+
if (isDeepStrictEqual(pinnedManifest, comparableLiveManifest)) {
64+
console.log(`check:contract: pin matches the live manifest at ${CONTRACT_URL}`)
65+
process.exit(0)
66+
}
67+
68+
// Section-level hints so the failure is actionable without eyeballing two full JSONs.
69+
const driftedSections = Object.keys({ ...pinnedManifest, ...comparableLiveManifest }).filter(
70+
(section) => !isDeepStrictEqual(pinnedManifest[section], comparableLiveManifest[section]),
71+
)
72+
73+
const operationHints = (() => {
74+
const pinnedOperations = new Map(
75+
(pinnedManifest.operations ?? []).map((operation) => [operation.request_type, operation]),
76+
)
77+
const liveOperations = new Map(
78+
(comparableLiveManifest.operations ?? []).map((operation) => [operation.request_type, operation]),
79+
)
80+
const requestTypes = new Set([...pinnedOperations.keys(), ...liveOperations.keys()])
81+
return [...requestTypes].flatMap((requestType) => {
82+
const pinnedOperation = pinnedOperations.get(requestType)
83+
const liveOperation = liveOperations.get(requestType)
84+
if (pinnedOperation === undefined) return [`operation ${requestType}: live only (missing from the pin)`]
85+
if (liveOperation === undefined) return [`operation ${requestType}: pin only (no longer served)`]
86+
if (isDeepStrictEqual(pinnedOperation, liveOperation)) return []
87+
return [`operation ${requestType}: differs`]
88+
})
89+
})()
90+
91+
console.error(`check:contract: the committed embed-api.json pin is out of date with ${CONTRACT_URL}`)
92+
console.error(` drifted sections: ${driftedSections.join(', ')}`)
93+
for (const hint of operationHints) {
94+
console.error(` - ${hint}`)
95+
}
96+
console.error('Run `npm run fix:contract` to re-sync, then commit embed-api.json + src/generated/.')
97+
process.exit(EXIT_CODES.stale_pin)

embed/src/generated/contract.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
export const LOCALES = ["fr", "en", "it", "de", "pt", "es", "ja", "nl"] as const
55
export type Locale = (typeof LOCALES)[number]
66

7-
export const EDITOR_ERROR_CODES = ["bad_request:editor_not_ready", "bad_request:event_not_allowed", "bad_request:field_not_found", "bad_request:invalid_dimensions", "bad_request:invalid_event_type", "bad_request:invalid_field_ids", "bad_request:invalid_field_type", "bad_request:invalid_page", "bad_request:invalid_signature_url", "bad_request:invalid_tool", "bad_request:invalid_value", "bad_request:missing_required_fields", "bad_request:no_document_loaded", "bad_request:page_not_found", "bad_request:page_out_of_range", "bad_request:plan_upgrade_required", "bad_request:read_only", "bad_request:signup_required", "forbidden:editing_not_allowed", "forbidden:origin_not_whitelisted", "forbidden:whitelist_required", "unexpected:internal_error"] as const
7+
export const EDITOR_ERROR_CODES = ["bad_request:download_blocked", "bad_request:editor_not_ready", "bad_request:event_not_allowed", "bad_request:field_not_found", "bad_request:invalid_dimensions", "bad_request:invalid_event_type", "bad_request:invalid_field_ids", "bad_request:invalid_field_type", "bad_request:invalid_page", "bad_request:invalid_signature_url", "bad_request:invalid_tool", "bad_request:invalid_value", "bad_request:missing_required_fields", "bad_request:no_document_loaded", "bad_request:page_not_found", "bad_request:page_out_of_range", "bad_request:plan_upgrade_required", "bad_request:read_only", "bad_request:signup_required", "forbidden:editing_not_allowed", "forbidden:origin_not_whitelisted", "forbidden:whitelist_required", "unexpected:internal_error"] as const
88
export type EditorErrorCode = (typeof EDITOR_ERROR_CODES)[number]
99

1010
export const FIELD_TYPES = ["TEXT", "SIGNATURE", "PICTURE", "CHECKBOX", "COMB_TEXT", "DROPDOWN", "RADIO"] as const
@@ -97,7 +97,7 @@ export const OPERATIONS = [
9797
wire_type: "DOWNLOAD",
9898
method: "download",
9999
description: "Generate and download the current document as a PDF. Returns no data.",
100-
error_codes: ["bad_request:no_document_loaded", "bad_request:missing_required_fields"] as const,
100+
error_codes: ["bad_request:no_document_loaded", "bad_request:missing_required_fields", "bad_request:download_blocked"] as const,
101101
is_agentic_tool: true,
102102
has_output: false,
103103
} /* Download */,

0 commit comments

Comments
 (0)