@contentrain/types 1.13.1 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +188 -1
- package/dist/index.d.mts +678 -8
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +512 -28
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -71,6 +71,16 @@ Core interfaces:
|
|
|
71
71
|
- `ScanSummaryResult`
|
|
72
72
|
- `ContextJson`
|
|
73
73
|
|
|
74
|
+
Execution/approval unions (see [Execution and approval contracts](#execution-and-approval-contracts)):
|
|
75
|
+
|
|
76
|
+
- `RiskClass`
|
|
77
|
+
- `ApprovalGate`
|
|
78
|
+
- `GrantRejection`
|
|
79
|
+
- `ApprovalMode`
|
|
80
|
+
- `RunStatus`
|
|
81
|
+
- `ScheduleKind`
|
|
82
|
+
- `SourceDeltaOp`
|
|
83
|
+
|
|
74
84
|
Storage/runtime helper types:
|
|
75
85
|
|
|
76
86
|
- `SingletonContentFile`
|
|
@@ -127,6 +137,7 @@ Serialize functions (pure, dependency-free):
|
|
|
127
137
|
- `generateEntryId()` — 12-char hex ID generation
|
|
128
138
|
- `parseMarkdownFrontmatter(content)` — parse YAML frontmatter + body from markdown
|
|
129
139
|
- `serializeMarkdownFrontmatter(data, body)` — serialize data + body into markdown frontmatter
|
|
140
|
+
- `parseFrontmatterScalar(raw)` / `parseFrontmatterScalarString(raw)` / `splitFrontmatterList(inner)` — the scalar grammar those two are built on, exported because a second reader needs it (see [Frontmatter round trip](#frontmatter-round-trip))
|
|
130
141
|
|
|
131
142
|
Constants:
|
|
132
143
|
|
|
@@ -244,6 +255,9 @@ Studio (Nuxt 4, web) cannot import `@contentrain/mcp` directly because MCP depen
|
|
|
244
255
|
| `canonicalStringify(data, fieldOrder?)` | Preview canonical JSON output |
|
|
245
256
|
| `parseMarkdownFrontmatter(content)` | Document editor frontmatter parsing |
|
|
246
257
|
| `serializeMarkdownFrontmatter(data, body)` | Document editor serialization |
|
|
258
|
+
| `parseFrontmatterScalar(raw)` | One frontmatter scalar: booleans, null, numbers, quoted strings with escapes decoded |
|
|
259
|
+
| `parseFrontmatterScalarString(raw)` | The same, always as a string — a SKU of `"007"` must not become `7` |
|
|
260
|
+
| `splitFrontmatterList(inner)` | Split an inline array on commas outside quotes |
|
|
247
261
|
| `generateEntryId()` | Client-side entry ID generation |
|
|
248
262
|
| `SECRET_PATTERNS` | Extend or customize secret detection |
|
|
249
263
|
| `looksLikeCredential(tail)` | Decide whether a value assigned to an API-key setting is a credential or documentation |
|
|
@@ -315,7 +329,7 @@ Shared shapes for the WordPress → static-site migration pipeline. They exist h
|
|
|
315
329
|
| Contract | Role |
|
|
316
330
|
|---|---|
|
|
317
331
|
| `RawIR` | Source-faithful extraction of a WordPress site (posts, terms, menus, comments, media, redirects) with provenance: which access rung produced it (`rest_public` → `rest_auth` → `wxr` → `bridge`). Unresolved references are kept and marked, never dropped. |
|
|
318
|
-
| `CapabilityManifest` | Evidence-based inventory of what the site uses (SEO, forms, comments, i18n, ACF, …) — the input for migration planning and the "what happens to X" conversation. |
|
|
332
|
+
| `CapabilityManifest` | Evidence-based inventory of what the site uses (SEO, forms, comments, i18n, ACF, …) — the input for migration planning and the "what happens to X" conversation. `access.achieved` is the highest source-access rung actually reached, or `null` when none was: a site behind an access wall still gets a manifest, and a producer that writes a rung it did not reach makes the coverage figures unreadable. |
|
|
319
333
|
| `ProjectIR` | The reproducible model of the site: route model, layout families, component variants, query bindings, design tokens. Not "this page's HTML" — the design system that generates unseen pages correctly. |
|
|
320
334
|
| `MigrationHandoff` | What the migration hands the user: repository, per-capability dispositions, and offers for runtime capabilities (with cost comparison) — offering is this document's job; fulfilling is the receiving product's. `runtime` (`RuntimeBinding`) records where the generated site's runtime components were bound once an offer was fulfilled. |
|
|
321
335
|
| `RuntimeBinding` | The provider's public API origin (`base_url`) and `project_id` — all a static site needs to mount comments and forms. Never a credential: the public endpoints are unauthenticated by design. |
|
|
@@ -328,3 +342,176 @@ Chrome markers the emitter honours: `CHROME_BODY_SLOT` (where page content goes)
|
|
|
328
342
|
`MODEL_EXTENSION_KEYS` identifies these preserved blocks; canonical model
|
|
329
343
|
serialization places them after `fields`. The content engine does not interpret
|
|
330
344
|
the runtime settings.
|
|
345
|
+
|
|
346
|
+
## Execution and approval contracts
|
|
347
|
+
|
|
348
|
+
`migration.ts` describes a site. `execution.ts` describes an act upon one — an
|
|
349
|
+
agent run, a bulk edit, a deploy, a cutover — and who had to say yes first.
|
|
350
|
+
|
|
351
|
+
Three repositories meet here and none may define these shapes for itself. The
|
|
352
|
+
migration engine produces plans and receipts, Studio renders the plan card and
|
|
353
|
+
collects approvals, and MCP is where a plan's steps execute. If each wrote its
|
|
354
|
+
own `RiskClass`, "destructive" would mean three different things and the
|
|
355
|
+
approval guarding it would be theatre.
|
|
356
|
+
|
|
357
|
+
| Contract | Role |
|
|
358
|
+
|---|---|
|
|
359
|
+
| `RiskClass` | What is at stake, as an ordered ladder: `read_only` → `low_risk_content` → `bulk_content` → `destructive_schema` → `external_effect` → `financially_material` → `deployment`. `highestRisk()` rates a plan by its worst step — a survey that ends in a deploy is a deploy. |
|
|
360
|
+
| `ApprovalGate` | The three distinct decision moments: `plan` (before the work, on scope and cost), `change` (on the produced diff), `release` (on production effect). Approving what will be done is not approving what was produced, and neither is permission to publish it. |
|
|
361
|
+
| `ApprovalRule` / `ApprovalPolicyFile` | `.contentrain/approval-policies.json` — which risk needs whose approval, in which mode (`auto` / `single` / `quorum`). In git, beside the content it governs, so the policy in force is the policy on the branch. Rules are additive: a policy file can only ever make a project stricter. |
|
|
362
|
+
| `ApprovalRequirement` / `ApprovalGrant` | An outstanding demand, and a decision actually given. A grant is bound to an exact `plan_hash` (and `commit_sha` for `change`): change the plan and its grants stop applying. An approval of "publish these 12 posts" must not carry over to a plan that publishes 400. |
|
|
363
|
+
| `ExecutionPlan` | An operation fully described before it runs: steps, union scope, risk, estimate, rollback, and the repository state it assumes. `ExecutionEstimate` meters cost before it is priced — `tokens`, `duration_ms`, `items`, `bytes_stored` (at rest) and `bytes_out` (moved); storage and egress are separate because they are billed by different rates against different quantities. |
|
|
364
|
+
| `ExecutionReceipt` | What happened: status, approvals, checkpoints, verification results, measured cost, and the scope actually touched — the same `ExecutionScope` shape as the plan, so prediction and outcome can be subtracted. Release approvers are the `approvals` entries with `gate: 'release'`; read them with `approversFor()`. |
|
|
365
|
+
| `DeploymentTarget` | Where a build is published. Carries a `secret_ref`, never a secret — this document is written to git. |
|
|
366
|
+
| `AutomationDefinition` | Reserved shape for `.contentrain/automations.json`. Nothing reads it yet; it exists so the first writer does not invent a fourth vocabulary for schedules. |
|
|
367
|
+
| `SourceDeltaPlan` | The WordPress→repo delta — *not* `contentrain_reconcile`, which merges two git branches and knows nothing about WordPress. Carries explicit deletion tombstones, because `modified_after` is a filter on changed records and never reports a deletion, plus slug moves (which generate redirects) and semantic conflicts. `deletions_detectable: false` must not be read as "nothing was deleted". |
|
|
368
|
+
|
|
369
|
+
### The evaluator
|
|
370
|
+
|
|
371
|
+
`approval.ts` is the decision procedure over these shapes: given a plan, a
|
|
372
|
+
policy and the decisions collected so far, may this proceed?
|
|
373
|
+
|
|
374
|
+
```ts
|
|
375
|
+
import { evaluateApproval, requiredApprovals } from '@contentrain/types'
|
|
376
|
+
|
|
377
|
+
// For the plan card, before anyone has decided:
|
|
378
|
+
requiredApprovals(plan, policy)
|
|
379
|
+
// → [{ gate: 'release', mode: 'quorum', min_approvals: 2, because: 'deployment' }]
|
|
380
|
+
|
|
381
|
+
// At the gate:
|
|
382
|
+
const decision = evaluateApproval({ plan, policy, grants, commit_sha, now })
|
|
383
|
+
decision.allowed // every requirement met and the plan has not expired
|
|
384
|
+
decision.outstanding // what is still missing, with who has signed so far
|
|
385
|
+
decision.rejected_grants // decisions that did not count, each with a reason
|
|
386
|
+
decision.reasons // one line per blocker, written for a person
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
It replaces a role check. `shouldAutoMerge` asks "is this person an owner?",
|
|
390
|
+
which cannot express "a bulk publish needs a second pair of eyes even from the
|
|
391
|
+
owner" and cannot tell a typo fix from a domain cutover. This asks about the
|
|
392
|
+
action: its risk, its scope, and what the project's policy says about that
|
|
393
|
+
combination.
|
|
394
|
+
|
|
395
|
+
Rules that matter:
|
|
396
|
+
|
|
397
|
+
- **A plan cannot understate itself.** `effectiveRisk()` takes the worst of the
|
|
398
|
+
plan's declared class and its steps', so a plan labelled `read_only` that
|
|
399
|
+
carries a deploy step is evaluated as a deploy.
|
|
400
|
+
- **Each matching rule is its own requirement and all must be met.** Merging two
|
|
401
|
+
rules would need a way to combine their modes, roles and counts, and every
|
|
402
|
+
such rule has a case where the result is *looser* than one of its inputs.
|
|
403
|
+
- **`auto` does not climb the ladder.** Every other mode covers its class and
|
|
404
|
+
everything above it. If `auto` did too, one `auto` rule on a low rung would
|
|
405
|
+
exempt every heavier operation above it — the one mode that demands nothing
|
|
406
|
+
would become the only mode that can loosen a policy.
|
|
407
|
+
- **An agent never approves.** Not its own work, not anyone's. The author of a
|
|
408
|
+
plan cannot approve it either unless the project sets `allow_self_approval`,
|
|
409
|
+
and that setting does not extend to agents.
|
|
410
|
+
- **A `change` decision is a decision about a diff.** Presented with a different
|
|
411
|
+
branch tip than the one reviewed, it does not count.
|
|
412
|
+
- **`now` is an input.** Nothing reads the clock, so a blocked run can be
|
|
413
|
+
explained months later by replaying the same arguments.
|
|
414
|
+
|
|
415
|
+
Every rejected decision carries a machine-readable reason
|
|
416
|
+
(`plan_hash_mismatch`, `commit_mismatch`, `expired`, `agent_approver`,
|
|
417
|
+
`self_approval`, `role_not_permitted`, `duplicate_approver`,
|
|
418
|
+
`no_matching_requirement`), because the useful question is never "is it
|
|
419
|
+
blocked" but "I approved this, why is it still blocked".
|
|
420
|
+
|
|
421
|
+
With no `.contentrain/approval-policies.json`, `DEFAULT_APPROVAL_POLICY`
|
|
422
|
+
applies: read-only work proceeds, everything else wants one reviewer on the
|
|
423
|
+
diff. Whether a project consults the evaluator at all is still governed by its
|
|
424
|
+
`workflow` setting.
|
|
425
|
+
|
|
426
|
+
### `plan_hash`
|
|
427
|
+
|
|
428
|
+
`computePlanHash(plan)` is SHA-256 over `planHashPayload(plan)`: canonical JSON
|
|
429
|
+
(sorted keys, 2-space indent, trailing newline) of the plan's semantic fields.
|
|
430
|
+
|
|
431
|
+
Excluded from the payload: `plan_hash` itself, and `id`, `created_at`,
|
|
432
|
+
`created_by`, `idempotency_key` — who built a plan, when, under which run id and
|
|
433
|
+
with which deduplication key do not change what the plan will do, and
|
|
434
|
+
regenerating the same operation must produce the same hash or idempotency and
|
|
435
|
+
approval binding both break. Everything else is covered, so a widened scope, an
|
|
436
|
+
added step, a raised estimate or a withdrawn rollback each invalidate every
|
|
437
|
+
approval the plan had collected.
|
|
438
|
+
|
|
439
|
+
It is async because it uses Web Crypto (`crypto.subtle`), available in Node 18+,
|
|
440
|
+
Deno, Bun, workers and browsers alike — this package is consumed in all of them
|
|
441
|
+
and must not reach for `node:crypto`. A non-cryptographic hash was rejected:
|
|
442
|
+
approvals are pinned to this value, so a collision is an approval bypass.
|
|
443
|
+
|
|
444
|
+
The digest is reproducible from the contract alone — the test suite pins a
|
|
445
|
+
golden hash cross-checked against an independent canonical-JSON + SHA-256
|
|
446
|
+
implementation in another language.
|
|
447
|
+
|
|
448
|
+
### Reserved paths
|
|
449
|
+
|
|
450
|
+
`RESERVED_PATHS` names four files under `.contentrain/` that this repository has
|
|
451
|
+
claimed but does not yet write: `capabilities.json`, `automations.json`,
|
|
452
|
+
`approval-policies.json`, `redirects.json`. `.contentrain/` is a shared
|
|
453
|
+
namespace — Studio, the migration engine and a customer's own tooling all write
|
|
454
|
+
into it — so a name claimed here cannot later be taken for something else, and
|
|
455
|
+
the tools that walk the directory know these four are expected rather than
|
|
456
|
+
stray.
|
|
457
|
+
|
|
458
|
+
Until a tool owns one, the contract is narrow and pinned by tests in
|
|
459
|
+
`@contentrain/mcp`:
|
|
460
|
+
|
|
461
|
+
- `contentrain_doctor` and `contentrain_validate` ignore them completely. They
|
|
462
|
+
are not orphans, not broken content, and not the validator's business.
|
|
463
|
+
- `contentrain_reconcile` treats each as one opaque file: it takes the side that
|
|
464
|
+
changed it, and reports `file_conflict` when both sides did. It never merges
|
|
465
|
+
their interiors, because it does not know their interiors — a field-level
|
|
466
|
+
union on an approval policy would produce a policy nobody wrote.
|
|
467
|
+
|
|
468
|
+
## Frontmatter round trip
|
|
469
|
+
|
|
470
|
+
A document's fields live in YAML frontmatter, and two readers open them: the
|
|
471
|
+
content engine, through `parseMarkdownFrontmatter` (which `@contentrain/mcp`
|
|
472
|
+
re-exports), and `@contentrain/query`'s client generator and Astro loader. The
|
|
473
|
+
property both depend on is that a value written and read back is the same
|
|
474
|
+
value — and for four shapes it did not hold.
|
|
475
|
+
|
|
476
|
+
| Value | Came back as |
|
|
477
|
+
|---|---|
|
|
478
|
+
| `He said "Hi"` | `He said \"Hi\"` — the quotes were stripped without decoding the escapes |
|
|
479
|
+
| `C:\path\to` | `C:\\path\\to` — and doubling again on every further save |
|
|
480
|
+
| `line one`⏎`line two` | `line one` — the rest was written as frontmatter lines the reader then skipped |
|
|
481
|
+
| ` padded ` | `padded` |
|
|
482
|
+
| `'42'` (a string) | `42` (a number) |
|
|
483
|
+
| `true` (a boolean) | `'true'` (a string) |
|
|
484
|
+
|
|
485
|
+
All six are fixed, and the guarantee is now explicit: **for every value
|
|
486
|
+
`serializeMarkdownFrontmatter` can write, `parseMarkdownFrontmatter` returns it
|
|
487
|
+
unchanged, and a second round trip produces identical bytes.** The second trip
|
|
488
|
+
is part of the test on purpose — backslash doubling only diverges on the trip
|
|
489
|
+
after the one that introduced it, so a single-trip test passes on content that
|
|
490
|
+
corrupts a little more with every export.
|
|
491
|
+
|
|
492
|
+
What that required:
|
|
493
|
+
|
|
494
|
+
- A quoted scalar's escapes are decoded (`\\`, `\"`, `\n`, `\r`, `\t`, `\uXXXX`).
|
|
495
|
+
An unrecognised escape keeps its backslash rather than erroring — hand-written
|
|
496
|
+
frontmatter says `"C:\Users"`, and losing that to strictness is a worse trade
|
|
497
|
+
than keeping the bytes. Text that merely starts and ends with a quote
|
|
498
|
+
(`"a" and "b"`) is not treated as one scalar, because slicing its ends off
|
|
499
|
+
would corrupt it.
|
|
500
|
+
- A value carrying a newline, tab, backslash or edge whitespace is quoted and
|
|
501
|
+
escaped, so it occupies one line and no part of it is silently dropped.
|
|
502
|
+
- A **string** that would read back as another type is quoted; a real boolean,
|
|
503
|
+
number or null is not, so each reads back as itself.
|
|
504
|
+
- An empty array is written `key: []`. A bare `key:` is genuinely ambiguous —
|
|
505
|
+
empty array, empty object, or null — and the two readers were guessing it
|
|
506
|
+
differently.
|
|
507
|
+
|
|
508
|
+
The scalar grammar is exported (`parseFrontmatterScalar`,
|
|
509
|
+
`parseFrontmatterScalarString`, `splitFrontmatterList`) and imported by the SDK
|
|
510
|
+
reader rather than replicated. That is the actual fix: when each side had its
|
|
511
|
+
own copy, correcting one of them would have turned a shared bug into a silent
|
|
512
|
+
disagreement between the generated client and the content engine. A parity suite
|
|
513
|
+
in `@contentrain/query` asserts both readers return the same values for the same
|
|
514
|
+
bytes.
|
|
515
|
+
|
|
516
|
+
Body text keeps its internal blank lines; only its leading and trailing
|
|
517
|
+
whitespace is normalised, which is markdown behaviour rather than loss.
|