@growth-labs/cms 0.5.29 → 0.5.31
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/dist/engine/foundry-dispatch.d.ts +7 -2
- package/dist/engine/foundry-dispatch.d.ts.map +1 -1
- package/dist/engine/foundry-dispatch.js +24 -6
- package/dist/engine/foundry-dispatch.js.map +1 -1
- package/dist/engine/fronts-publish-intent.d.ts +174 -0
- package/dist/engine/fronts-publish-intent.d.ts.map +1 -0
- package/dist/engine/fronts-publish-intent.js +352 -0
- package/dist/engine/fronts-publish-intent.js.map +1 -0
- package/dist/engine/fronts-publish.d.ts +80 -0
- package/dist/engine/fronts-publish.d.ts.map +1 -0
- package/dist/engine/fronts-publish.js +286 -0
- package/dist/engine/fronts-publish.js.map +1 -0
- package/dist/engine/index.d.ts +7 -0
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +10 -0
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/publication-continuity.d.ts +77 -0
- package/dist/engine/publication-continuity.d.ts.map +1 -0
- package/dist/engine/publication-continuity.js +94 -0
- package/dist/engine/publication-continuity.js.map +1 -0
- package/dist/engine/publication-receipt/canonical-json.d.ts +34 -0
- package/dist/engine/publication-receipt/canonical-json.d.ts.map +1 -0
- package/dist/engine/publication-receipt/canonical-json.js +100 -0
- package/dist/engine/publication-receipt/canonical-json.js.map +1 -0
- package/dist/engine/publication-receipt/receipt.d.ts +316 -0
- package/dist/engine/publication-receipt/receipt.d.ts.map +1 -0
- package/dist/engine/publication-receipt/receipt.js +131 -0
- package/dist/engine/publication-receipt/receipt.js.map +1 -0
- package/dist/engine/publication-receipt/signature.d.ts +36 -0
- package/dist/engine/publication-receipt/signature.d.ts.map +1 -0
- package/dist/engine/publication-receipt/signature.js +79 -0
- package/dist/engine/publication-receipt/signature.js.map +1 -0
- package/dist/engine/publication.d.ts.map +1 -1
- package/dist/engine/publication.js +2 -0
- package/dist/engine/publication.js.map +1 -1
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +23 -1
- package/dist/engine/publisher.js.map +1 -1
- package/dist/engine/source-sections.d.ts +20 -0
- package/dist/engine/source-sections.d.ts.map +1 -0
- package/dist/engine/source-sections.js +130 -0
- package/dist/engine/source-sections.js.map +1 -0
- package/dist/engine/validator/checks/source-section-diagnostics.d.ts +7 -0
- package/dist/engine/validator/checks/source-section-diagnostics.d.ts.map +1 -0
- package/dist/engine/validator/checks/source-section-diagnostics.js +120 -0
- package/dist/engine/validator/checks/source-section-diagnostics.js.map +1 -0
- package/dist/engine/validator/index.d.ts +10 -0
- package/dist/engine/validator/index.d.ts.map +1 -1
- package/dist/engine/validator/index.js +4 -0
- package/dist/engine/validator/index.js.map +1 -1
- package/dist/engine/validator/types.d.ts +1 -1
- package/dist/engine/validator/types.d.ts.map +1 -1
- package/dist/engine/validator/types.js +10 -0
- package/dist/engine/validator/types.js.map +1 -1
- package/dist/integration/index.d.ts.map +1 -1
- package/dist/integration/index.js +10 -0
- package/dist/integration/index.js.map +1 -1
- package/dist/providers/types.d.ts +1 -0
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +22 -2
- package/dist/routes/content.js.map +1 -1
- package/dist/routes/fronts-publish.d.ts +10 -0
- package/dist/routes/fronts-publish.d.ts.map +1 -0
- package/dist/routes/fronts-publish.js +250 -0
- package/dist/routes/fronts-publish.js.map +1 -0
- package/dist/routes/index.d.ts +3 -0
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/index.js +4 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/surveys.d.ts.map +1 -1
- package/dist/routes/surveys.js +13 -1
- package/dist/routes/surveys.js.map +1 -1
- package/dist/schema/insights-ingest.d.ts +28 -28
- package/dist/schema/layout.d.ts +2 -2
- package/dist/schema/migrations.d.ts.map +1 -1
- package/dist/schema/migrations.js +36 -0
- package/dist/schema/migrations.js.map +1 -1
- package/dist/schema/portable-text.d.ts +2 -2
- package/dist/schema/tables.d.ts +1 -1
- package/dist/schema/tables.d.ts.map +1 -1
- package/dist/schema/tables.js +2 -0
- package/dist/schema/tables.js.map +1 -1
- package/dist/schema/types.d.ts +59 -0
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/types.js.map +1 -1
- package/dist/surveys/access.d.ts +2 -0
- package/dist/surveys/access.d.ts.map +1 -1
- package/dist/surveys/access.js +7 -0
- package/dist/surveys/access.js.map +1 -1
- package/dist/surveys/errors.d.ts +8 -0
- package/dist/surveys/errors.d.ts.map +1 -1
- package/dist/surveys/errors.js +12 -0
- package/dist/surveys/errors.js.map +1 -1
- package/dist/surveys/schema.d.ts +129 -54
- package/dist/surveys/schema.d.ts.map +1 -1
- package/dist/surveys/schema.js +45 -0
- package/dist/surveys/schema.js.map +1 -1
- package/dist/ui/api/fronts/publish-callback.d.ts +3 -0
- package/dist/ui/api/fronts/publish-callback.d.ts.map +1 -0
- package/dist/ui/api/fronts/publish-callback.js +8 -0
- package/dist/ui/api/fronts/publish-callback.js.map +1 -0
- package/dist/ui/api/fronts/schedule.d.ts +3 -0
- package/dist/ui/api/fronts/schedule.d.ts.map +1 -0
- package/dist/ui/api/fronts/schedule.js +8 -0
- package/dist/ui/api/fronts/schedule.js.map +1 -0
- package/dist/ui/editor/ContentForm.d.ts.map +1 -1
- package/dist/ui/editor/ContentForm.js +52 -1
- package/dist/ui/editor/ContentForm.js.map +1 -1
- package/dist/ui/editor/body-warnings.d.ts +27 -0
- package/dist/ui/editor/body-warnings.d.ts.map +1 -0
- package/dist/ui/editor/body-warnings.js +58 -0
- package/dist/ui/editor/body-warnings.js.map +1 -0
- package/dist/ui/screens/SurveyResponsesScreen.d.ts +7 -1
- package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
- package/dist/ui/screens/SurveyResponsesScreen.js +141 -32
- package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -1
- package/migrations/0026_fronts_publish.sql +31 -0
- package/migrations/0027_fronts_publish_intent.sql +44 -0
- package/package.json +1 -1
- package/src/engine/foundry-dispatch.ts +22 -6
- package/src/engine/fronts-publish-intent.ts +676 -0
- package/src/engine/fronts-publish.ts +417 -0
- package/src/engine/index.ts +60 -0
- package/src/engine/publication-continuity.ts +140 -0
- package/src/engine/publication-receipt/canonical-json.ts +123 -0
- package/src/engine/publication-receipt/receipt.ts +160 -0
- package/src/engine/publication-receipt/signature.ts +112 -0
- package/src/engine/publication.ts +2 -0
- package/src/engine/publisher.ts +23 -1
- package/src/engine/source-sections.ts +153 -0
- package/src/engine/validator/checks/source-section-diagnostics.ts +187 -0
- package/src/engine/validator/index.ts +12 -0
- package/src/engine/validator/types.ts +19 -0
- package/src/integration/index.ts +10 -0
- package/src/providers/types.ts +1 -1
- package/src/routes/content.ts +23 -2
- package/src/routes/fronts-publish.ts +292 -0
- package/src/routes/index.ts +8 -0
- package/src/routes/surveys.ts +12 -1
- package/src/schema/migrations.ts +36 -0
- package/src/schema/tables.ts +2 -0
- package/src/schema/types.ts +61 -0
- package/src/surveys/access.ts +9 -0
- package/src/surveys/errors.ts +12 -0
- package/src/surveys/schema.ts +47 -0
- package/src/ui/api/fronts/publish-callback.ts +18 -0
- package/src/ui/api/fronts/schedule.ts +18 -0
- package/src/ui/editor/ContentForm.tsx +93 -0
- package/src/ui/editor/body-warnings.ts +78 -0
- package/src/ui/screens/SurveyResponsesScreen.tsx +369 -121
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// RFC 8785 JSON Canonicalization Scheme (#342).
|
|
2
|
+
//
|
|
3
|
+
// A publication receipt is hashed and signed, and the same bytes must be
|
|
4
|
+
// reproducible by Foundry from the same object. Canonicalization is what makes
|
|
5
|
+
// that true: `JSON.stringify` alone is not stable, because object key order
|
|
6
|
+
// follows insertion order rather than a defined sort.
|
|
7
|
+
//
|
|
8
|
+
// Deliberately implemented rather than pulled from a dependency. The whole
|
|
9
|
+
// value here is that both sides agree byte-for-byte, so the algorithm should be
|
|
10
|
+
// readable, pinned, and covered by the RFC's own vectors rather than tracking
|
|
11
|
+
// someone else's semver.
|
|
12
|
+
|
|
13
|
+
export class CanonicalJsonError extends Error {
|
|
14
|
+
readonly code: 'non_finite_number' | 'unsupported_type' | 'circular_reference'
|
|
15
|
+
|
|
16
|
+
constructor(code: CanonicalJsonError['code'], message: string) {
|
|
17
|
+
super(message)
|
|
18
|
+
this.name = 'CanonicalJsonError'
|
|
19
|
+
this.code = code
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type JsonPrimitive = string | number | boolean | null
|
|
24
|
+
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Serialize `value` per RFC 8785.
|
|
28
|
+
*
|
|
29
|
+
* Three things do the work, and each is load-bearing:
|
|
30
|
+
*
|
|
31
|
+
* 1. Object keys sort by UTF-16 code unit. JavaScript's default string sort is
|
|
32
|
+
* already a UTF-16 code-unit comparison, which is exactly what §3.2.3
|
|
33
|
+
* requires — so `.sort()` here is the specified behaviour, not a shortcut.
|
|
34
|
+
* 2. Numbers use ECMAScript `Number::toString`, which `JSON.stringify` applies.
|
|
35
|
+
* §3.2.2.3 defers to that same algorithm, so the platform is authoritative
|
|
36
|
+
* and reimplementing it would only introduce drift.
|
|
37
|
+
* 3. Strings use `JSON.stringify`'s escaping, which since ES2019 emits
|
|
38
|
+
* well-formed output (lone surrogates escaped rather than passed through) —
|
|
39
|
+
* §3.2.2.2's requirement.
|
|
40
|
+
*
|
|
41
|
+
* Non-finite numbers throw rather than becoming `null` as `JSON.stringify`
|
|
42
|
+
* would. A receipt containing `NaN` is a bug, and silently signing `null` in
|
|
43
|
+
* its place would make that bug undetectable downstream.
|
|
44
|
+
*/
|
|
45
|
+
export function canonicalJson(value: JsonValue): string {
|
|
46
|
+
return write(value, new Set())
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function write(value: JsonValue, seen: Set<object>): string {
|
|
50
|
+
if (value === null) return 'null'
|
|
51
|
+
|
|
52
|
+
switch (typeof value) {
|
|
53
|
+
case 'boolean':
|
|
54
|
+
return value ? 'true' : 'false'
|
|
55
|
+
|
|
56
|
+
case 'number': {
|
|
57
|
+
if (!Number.isFinite(value)) {
|
|
58
|
+
throw new CanonicalJsonError(
|
|
59
|
+
'non_finite_number',
|
|
60
|
+
`Cannot canonicalize non-finite number: ${String(value)}`,
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
// -0 serializes as "0" per RFC 8785 §3.2.2.3; JSON.stringify agrees.
|
|
64
|
+
return JSON.stringify(value)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
case 'string':
|
|
68
|
+
return JSON.stringify(value)
|
|
69
|
+
|
|
70
|
+
case 'object':
|
|
71
|
+
break
|
|
72
|
+
|
|
73
|
+
default:
|
|
74
|
+
throw new CanonicalJsonError(
|
|
75
|
+
'unsupported_type',
|
|
76
|
+
`Cannot canonicalize value of type ${typeof value}`,
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const object = value as object
|
|
81
|
+
if (seen.has(object)) {
|
|
82
|
+
throw new CanonicalJsonError('circular_reference', 'Cannot canonicalize a circular structure')
|
|
83
|
+
}
|
|
84
|
+
seen.add(object)
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
if (Array.isArray(value)) {
|
|
88
|
+
return `[${value.map((entry) => write(entry, seen)).join(',')}]`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const record = value as { [key: string]: JsonValue }
|
|
92
|
+
// Undefined-valued keys are omitted, matching JSON.stringify. RFC 8785
|
|
93
|
+
// has no representation for them.
|
|
94
|
+
const keys = Object.keys(record)
|
|
95
|
+
.filter((key) => record[key] !== undefined)
|
|
96
|
+
.sort()
|
|
97
|
+
|
|
98
|
+
const members = keys.map(
|
|
99
|
+
(key) => `${JSON.stringify(key)}:${write(record[key] as JsonValue, seen)}`,
|
|
100
|
+
)
|
|
101
|
+
return `{${members.join(',')}}`
|
|
102
|
+
} finally {
|
|
103
|
+
seen.delete(object)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Canonical UTF-8 bytes, which are what gets hashed and signed. */
|
|
108
|
+
export function canonicalJsonBytes(value: JsonValue): Uint8Array {
|
|
109
|
+
return new TextEncoder().encode(canonicalJson(value))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Lowercase hex SHA-256 of the canonical bytes. */
|
|
113
|
+
export async function canonicalJsonSha256(value: JsonValue): Promise<string> {
|
|
114
|
+
// `Uint8Array.from` yields an ArrayBuffer-backed copy. TextEncoder returns
|
|
115
|
+
// `Uint8Array<ArrayBufferLike>`, which may be SharedArrayBuffer-backed and
|
|
116
|
+
// so is not a `BufferSource`. Same idiom as sha256Hex in
|
|
117
|
+
// @growth-labs/monitoring's site-agent signature helper.
|
|
118
|
+
const copy = Uint8Array.from(canonicalJsonBytes(value))
|
|
119
|
+
const digest = await globalThis.crypto.subtle.digest('SHA-256', copy)
|
|
120
|
+
return Array.from(new Uint8Array(digest))
|
|
121
|
+
.map((byte) => byte.toString(16).padStart(2, '0'))
|
|
122
|
+
.join('')
|
|
123
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// `site.publication.receipt.v1` — the signed handoff receipt (#342).
|
|
2
|
+
//
|
|
3
|
+
// A receipt is the site's terminal, non-repudiable statement about one
|
|
4
|
+
// publication attempt. Foundry must be able to verify it without trusting the
|
|
5
|
+
// transport and without re-querying the site, so everything it needs to judge
|
|
6
|
+
// the claim is inside the signed body.
|
|
7
|
+
//
|
|
8
|
+
// The site persists the canonical bytes and signature exactly once BEFORE
|
|
9
|
+
// network delivery; a retry resends those stored bytes verbatim. That is why
|
|
10
|
+
// nothing in the body is derived at sign time — the nonce and timestamps are
|
|
11
|
+
// inputs, not defaults. A schema that generated its own nonce would produce a
|
|
12
|
+
// different receipt on every retry and break replay-safety by construction.
|
|
13
|
+
|
|
14
|
+
import { z } from 'zod'
|
|
15
|
+
import { canonicalJson, type JsonValue } from './canonical-json.js'
|
|
16
|
+
|
|
17
|
+
export const SITE_PUBLICATION_RECEIPT_SCHEMA = 'site.publication.receipt.v1' as const
|
|
18
|
+
export const SITE_PUBLICATION_RECEIPT_AUDIENCE = 'foundry:site-publication-receipt' as const
|
|
19
|
+
|
|
20
|
+
/** Lowercase hex SHA-256. */
|
|
21
|
+
const sha256Hex = z.string().regex(/^[0-9a-f]{64}$/, 'expected lowercase hex sha-256')
|
|
22
|
+
|
|
23
|
+
/** A bounded identifier: printable ASCII, no whitespace, capped. */
|
|
24
|
+
const identifier = z
|
|
25
|
+
.string()
|
|
26
|
+
.min(1)
|
|
27
|
+
.max(256)
|
|
28
|
+
.regex(/^[\x21-\x7e]+$/, 'expected a bounded printable identifier')
|
|
29
|
+
|
|
30
|
+
/** RFC 3339 UTC instant, seconds precision, `Z` only — no local offsets. */
|
|
31
|
+
const instant = z
|
|
32
|
+
.string()
|
|
33
|
+
.regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/, 'expected an RFC 3339 UTC instant')
|
|
34
|
+
|
|
35
|
+
/** A count that must stay bounded so a receipt cannot carry unbounded claims. */
|
|
36
|
+
const boundedCount = z.number().int().min(0).max(1_000_000)
|
|
37
|
+
|
|
38
|
+
export const publicationProbeSchema = z
|
|
39
|
+
.object({
|
|
40
|
+
/** What was probed, e.g. `html` or `markdown`. */
|
|
41
|
+
kind: z.enum(['html', 'markdown']),
|
|
42
|
+
url: z.string().url().max(2048),
|
|
43
|
+
status: z.number().int().min(100).max(599),
|
|
44
|
+
/** SHA-256 of the exact bytes observed, so Foundry can compare. */
|
|
45
|
+
digest: sha256Hex,
|
|
46
|
+
})
|
|
47
|
+
.strict()
|
|
48
|
+
|
|
49
|
+
export const publicationProjectionCountsSchema = z
|
|
50
|
+
.object({
|
|
51
|
+
seo: boundedCount,
|
|
52
|
+
search: boundedCount,
|
|
53
|
+
sitemap: boundedCount,
|
|
54
|
+
feed: boundedCount,
|
|
55
|
+
})
|
|
56
|
+
.strict()
|
|
57
|
+
|
|
58
|
+
export const sitePublicationReceiptBodySchema = z
|
|
59
|
+
.object({
|
|
60
|
+
schema: z.literal(SITE_PUBLICATION_RECEIPT_SCHEMA),
|
|
61
|
+
audience: z.literal(SITE_PUBLICATION_RECEIPT_AUDIENCE),
|
|
62
|
+
|
|
63
|
+
/** Success and failure are both signed; a silent failure is unverifiable. */
|
|
64
|
+
outcome: z.enum(['success', 'failure']),
|
|
65
|
+
|
|
66
|
+
/** Identity of the publishing property (the site). */
|
|
67
|
+
property: z.object({ id: identifier, origin: z.string().url().max(2048) }).strict(),
|
|
68
|
+
|
|
69
|
+
/** Identity of the thing published, stable across revisions. */
|
|
70
|
+
content: z
|
|
71
|
+
.object({
|
|
72
|
+
stableId: identifier,
|
|
73
|
+
slug: identifier,
|
|
74
|
+
revisionId: identifier,
|
|
75
|
+
})
|
|
76
|
+
.strict(),
|
|
77
|
+
|
|
78
|
+
/** What produced it. */
|
|
79
|
+
source: z
|
|
80
|
+
.object({
|
|
81
|
+
commit: z.string().regex(/^[0-9a-f]{40}$/, 'expected a full git sha'),
|
|
82
|
+
foundryHandoffId: identifier,
|
|
83
|
+
foundryRunId: identifier,
|
|
84
|
+
})
|
|
85
|
+
.strict(),
|
|
86
|
+
|
|
87
|
+
/** Logical database identity — never a connection string or credential. */
|
|
88
|
+
database: z.object({ logicalId: identifier }).strict(),
|
|
89
|
+
|
|
90
|
+
/** Hash of the public media manifest, not the media itself. */
|
|
91
|
+
publicMediaManifestHash: sha256Hex,
|
|
92
|
+
|
|
93
|
+
projections: publicationProjectionCountsSchema,
|
|
94
|
+
probes: z.array(publicationProbeSchema).max(16),
|
|
95
|
+
|
|
96
|
+
/** How to undo this publication. */
|
|
97
|
+
rollback: z.object({ pointer: identifier }).strict(),
|
|
98
|
+
|
|
99
|
+
/** Replay identity. Supplied by the caller and persisted, never generated here. */
|
|
100
|
+
nonce: identifier,
|
|
101
|
+
issuedAt: instant,
|
|
102
|
+
|
|
103
|
+
/** Present only when `outcome` is `failure`, and bounded. */
|
|
104
|
+
failureReason: z.string().min(1).max(512).optional(),
|
|
105
|
+
})
|
|
106
|
+
.strict()
|
|
107
|
+
.superRefine((body, ctx) => {
|
|
108
|
+
if (body.outcome === 'failure' && body.failureReason === undefined) {
|
|
109
|
+
ctx.addIssue({
|
|
110
|
+
code: z.ZodIssueCode.custom,
|
|
111
|
+
path: ['failureReason'],
|
|
112
|
+
message: 'a failure receipt must state its reason',
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
if (body.outcome === 'success' && body.failureReason !== undefined) {
|
|
116
|
+
ctx.addIssue({
|
|
117
|
+
code: z.ZodIssueCode.custom,
|
|
118
|
+
path: ['failureReason'],
|
|
119
|
+
message: 'a success receipt must not carry a failure reason',
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
export type SitePublicationReceiptBody = z.infer<typeof sitePublicationReceiptBodySchema>
|
|
125
|
+
|
|
126
|
+
export class PublicationReceiptError extends Error {
|
|
127
|
+
readonly code: 'invalid_body' | 'invalid_key'
|
|
128
|
+
|
|
129
|
+
constructor(code: PublicationReceiptError['code'], message: string) {
|
|
130
|
+
super(message)
|
|
131
|
+
this.name = 'PublicationReceiptError'
|
|
132
|
+
this.code = code
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Validate a receipt body and return its canonical RFC 8785 form.
|
|
138
|
+
*
|
|
139
|
+
* Validation happens before canonicalization on purpose: signing an unvalidated
|
|
140
|
+
* body would produce a verifiable signature over a malformed claim, which is
|
|
141
|
+
* strictly worse than refusing, because downstream would trust it.
|
|
142
|
+
*/
|
|
143
|
+
export function canonicalizeReceiptBody(body: unknown): {
|
|
144
|
+
body: SitePublicationReceiptBody
|
|
145
|
+
canonical: string
|
|
146
|
+
} {
|
|
147
|
+
const parsed = sitePublicationReceiptBodySchema.safeParse(body)
|
|
148
|
+
if (!parsed.success) {
|
|
149
|
+
throw new PublicationReceiptError(
|
|
150
|
+
'invalid_body',
|
|
151
|
+
`Receipt body failed validation: ${parsed.error.issues
|
|
152
|
+
.map((issue) => `${issue.path.join('.')}: ${issue.message}`)
|
|
153
|
+
.join('; ')}`,
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
body: parsed.data,
|
|
158
|
+
canonical: canonicalJson(parsed.data as unknown as JsonValue),
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Ed25519 signature envelope for `site.publication.receipt.v1` (#342).
|
|
2
|
+
//
|
|
3
|
+
// Signs the RFC 8785 canonical bytes of the receipt body, so the signature is
|
|
4
|
+
// over exactly what Foundry will re-canonicalize and compare — not over a
|
|
5
|
+
// re-serialization that might differ in key order.
|
|
6
|
+
//
|
|
7
|
+
// crypto.subtle only; never node:crypto. The package runs in Workers.
|
|
8
|
+
|
|
9
|
+
import { canonicalJsonSha256 } from './canonical-json.js'
|
|
10
|
+
import {
|
|
11
|
+
canonicalizeReceiptBody,
|
|
12
|
+
PublicationReceiptError,
|
|
13
|
+
SITE_PUBLICATION_RECEIPT_AUDIENCE,
|
|
14
|
+
type SitePublicationReceiptBody,
|
|
15
|
+
sitePublicationReceiptBodySchema,
|
|
16
|
+
} from './receipt.js'
|
|
17
|
+
|
|
18
|
+
export interface SignedPublicationReceipt {
|
|
19
|
+
body: SitePublicationReceiptBody
|
|
20
|
+
/** The exact bytes that were signed. Persist these, not a re-serialization. */
|
|
21
|
+
canonical: string
|
|
22
|
+
/** Lowercase hex SHA-256 of `canonical`. */
|
|
23
|
+
bodyHash: string
|
|
24
|
+
/** Base64url, unpadded. */
|
|
25
|
+
signature: string
|
|
26
|
+
/** Key id the verifier should select. */
|
|
27
|
+
kid: string
|
|
28
|
+
audience: typeof SITE_PUBLICATION_RECEIPT_AUDIENCE
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function toBase64Url(bytes: Uint8Array): string {
|
|
32
|
+
let binary = ''
|
|
33
|
+
for (const byte of bytes) binary += String.fromCharCode(byte)
|
|
34
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function fromBase64Url(value: string): Uint8Array {
|
|
38
|
+
if (!/^[A-Za-z0-9_-]+$/.test(value)) {
|
|
39
|
+
throw new PublicationReceiptError('invalid_body', 'Signature is not unpadded base64url')
|
|
40
|
+
}
|
|
41
|
+
const padded = value.replace(/-/g, '+').replace(/_/g, '/')
|
|
42
|
+
const binary = atob(padded + '='.repeat((4 - (padded.length % 4)) % 4))
|
|
43
|
+
const bytes = new Uint8Array(binary.length)
|
|
44
|
+
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index)
|
|
45
|
+
return bytes
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** ArrayBuffer-backed copy — TextEncoder output is not a `BufferSource`. */
|
|
49
|
+
function encode(value: string): Uint8Array<ArrayBuffer> {
|
|
50
|
+
return Uint8Array.from(new TextEncoder().encode(value))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Validate, canonicalize and sign a receipt body.
|
|
55
|
+
*
|
|
56
|
+
* Deterministic: the same body and key always produce the same bytes and the
|
|
57
|
+
* same signature, which is what makes "retries resend byte-identical bytes"
|
|
58
|
+
* achievable. Nothing is generated here — the nonce and `issuedAt` come from
|
|
59
|
+
* the caller, who is responsible for persisting them once.
|
|
60
|
+
*/
|
|
61
|
+
export async function signPublicationReceipt(
|
|
62
|
+
body: unknown,
|
|
63
|
+
key: CryptoKey,
|
|
64
|
+
kid: string,
|
|
65
|
+
): Promise<SignedPublicationReceipt> {
|
|
66
|
+
if (key.type !== 'private') {
|
|
67
|
+
throw new PublicationReceiptError('invalid_key', 'Signing requires an Ed25519 private key')
|
|
68
|
+
}
|
|
69
|
+
const { body: validated, canonical } = canonicalizeReceiptBody(body)
|
|
70
|
+
const signature = await globalThis.crypto.subtle.sign({ name: 'Ed25519' }, key, encode(canonical))
|
|
71
|
+
return {
|
|
72
|
+
body: validated,
|
|
73
|
+
canonical,
|
|
74
|
+
bodyHash: await canonicalJsonSha256(validated as never),
|
|
75
|
+
signature: toBase64Url(new Uint8Array(signature)),
|
|
76
|
+
kid,
|
|
77
|
+
audience: SITE_PUBLICATION_RECEIPT_AUDIENCE,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Verify a receipt as Foundry would: re-canonicalize the body from scratch and
|
|
83
|
+
* check the signature over THOSE bytes.
|
|
84
|
+
*
|
|
85
|
+
* Deliberately ignores any `canonical` string supplied alongside the receipt. A
|
|
86
|
+
* verifier that trusts the sender's canonical form would accept a signature
|
|
87
|
+
* over bytes that do not match the body it then acts on — which is the whole
|
|
88
|
+
* attack canonicalization exists to prevent.
|
|
89
|
+
*/
|
|
90
|
+
export async function verifyPublicationReceipt(
|
|
91
|
+
receipt: { body: unknown; signature: string },
|
|
92
|
+
key: CryptoKey,
|
|
93
|
+
): Promise<boolean> {
|
|
94
|
+
const parsed = sitePublicationReceiptBodySchema.safeParse(receipt.body)
|
|
95
|
+
if (!parsed.success) return false
|
|
96
|
+
if (parsed.data.audience !== SITE_PUBLICATION_RECEIPT_AUDIENCE) return false
|
|
97
|
+
|
|
98
|
+
let signatureBytes: Uint8Array
|
|
99
|
+
try {
|
|
100
|
+
signatureBytes = fromBase64Url(receipt.signature)
|
|
101
|
+
} catch {
|
|
102
|
+
return false
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const { canonical } = canonicalizeReceiptBody(parsed.data)
|
|
106
|
+
return await globalThis.crypto.subtle.verify(
|
|
107
|
+
{ name: 'Ed25519' },
|
|
108
|
+
key,
|
|
109
|
+
Uint8Array.from(signatureBytes),
|
|
110
|
+
encode(canonical),
|
|
111
|
+
)
|
|
112
|
+
}
|
package/src/engine/publisher.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import type { ContentType } from '../schema/types.js'
|
|
23
23
|
import { type ContentMetadata, serializeContentMetadata } from './content-metadata.js'
|
|
24
24
|
import type { D1Database } from './d1.js'
|
|
25
|
+
import { armFrontsPublishTriggerToken } from './fronts-publish-intent.js'
|
|
25
26
|
import { evaluateArticleBody, type PublishGuardOutcome } from './publish-guard.js'
|
|
26
27
|
import { slugify } from './slug.js'
|
|
27
28
|
import {
|
|
@@ -948,7 +949,13 @@ export async function updateContentItem(
|
|
|
948
949
|
ai_locked_fields = ?,
|
|
949
950
|
seo_focus_keyword = ?,
|
|
950
951
|
metadata_json = ?,
|
|
951
|
-
updated_at = unixepoch()
|
|
952
|
+
updated_at = unixepoch(),
|
|
953
|
+
-- The canonical revision counter behind the Fronts publish intent id
|
|
954
|
+
-- masthead:<content_id>:<version> (migration 0027). This is the ONE seam
|
|
955
|
+
-- that advances it, so an intent identity changes exactly when the item's
|
|
956
|
+
-- canonical content does -- and never on a fronts_publish_status marker
|
|
957
|
+
-- write or a schedule poll.
|
|
958
|
+
canonical_version = canonical_version + 1
|
|
952
959
|
WHERE id = ?
|
|
953
960
|
`,
|
|
954
961
|
)
|
|
@@ -1172,6 +1179,12 @@ export async function scheduleContent(
|
|
|
1172
1179
|
)
|
|
1173
1180
|
.bind(publishAt, publishTz, id)
|
|
1174
1181
|
.run()
|
|
1182
|
+
// Arming an item for the additive Fronts publish path happens HERE — the one
|
|
1183
|
+
// seam through which an item becomes `scheduled` with a `publish_at`. The mint
|
|
1184
|
+
// is compare-and-set and never rotates an existing token, so an item's
|
|
1185
|
+
// `trigger_token_hash` is stable across reschedules and across every poll.
|
|
1186
|
+
// The raw token stays in D1; only `sha256:<hex>` is ever exposed.
|
|
1187
|
+
await armFrontsPublishTriggerToken(db, id)
|
|
1175
1188
|
}
|
|
1176
1189
|
|
|
1177
1190
|
export async function evaluateContentBodyForPublish(
|
|
@@ -1210,6 +1223,8 @@ export async function evaluateContentBodyForPublish(
|
|
|
1210
1223
|
source,
|
|
1211
1224
|
contentId: id,
|
|
1212
1225
|
requireBody: true,
|
|
1226
|
+
// #329: source-section defects block at publish, warn on save.
|
|
1227
|
+
blockSourceSectionDefects: true,
|
|
1213
1228
|
},
|
|
1214
1229
|
)
|
|
1215
1230
|
}
|
|
@@ -1302,6 +1317,13 @@ export async function duplicateContentItem(
|
|
|
1302
1317
|
// — a duplicate should keep the SEO work already done on the source. But
|
|
1303
1318
|
// board_position is the source item's slot in the editorial board ordering;
|
|
1304
1319
|
// a fresh draft copy must NOT inherit it, so it is reset to NULL.
|
|
1320
|
+
//
|
|
1321
|
+
// The migration-0027 Fronts publish columns are DELIBERATELY omitted so the
|
|
1322
|
+
// copy takes their defaults: canonical_version = 1 (a new item starts its own
|
|
1323
|
+
// revision line), fronts_channel_targets = NULL and
|
|
1324
|
+
// fronts_publish_trigger_token = NULL (a distinct item must be armed on its
|
|
1325
|
+
// own and must never inherit another item's publish trigger token). Do not
|
|
1326
|
+
// add them to the column list.
|
|
1305
1327
|
await db
|
|
1306
1328
|
.prepare(
|
|
1307
1329
|
`INSERT INTO content_items (
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Canonical normalization for article "Sources" / "References" sections
|
|
2
|
+
// (packages#329).
|
|
3
|
+
//
|
|
4
|
+
// Editors paste these sections out of Word and Google Docs, which produces
|
|
5
|
+
// Markdown that renders wrongly rather than not at all: manual `●` bullets
|
|
6
|
+
// become separate paragraphs, a bold line used as a heading becomes an empty
|
|
7
|
+
// `h2`, and `[**title**](url)` keeps its `**` inside the anchor text because
|
|
8
|
+
// renderers protect generated links before evaluating inline emphasis.
|
|
9
|
+
//
|
|
10
|
+
// This lives in the package, not in a site, so the CMS save path, preview
|
|
11
|
+
// path, import path and every site integration share one behaviour.
|
|
12
|
+
|
|
13
|
+
const NBSP = ' '
|
|
14
|
+
const SECTION_WORDS = ['sources', 'references'] as const
|
|
15
|
+
|
|
16
|
+
export interface SourceSectionNormalization {
|
|
17
|
+
markdown: string
|
|
18
|
+
/** True when normalization altered the document. Callers use this to decide
|
|
19
|
+
* whether a cached `body_html` render must be invalidated. */
|
|
20
|
+
changed: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** `## Sources`, `### References:` — returns its level and canonical word. */
|
|
24
|
+
function matchHeadingBoundary(line: string): { level: number; word: string } | null {
|
|
25
|
+
const match = /^(#{1,6})\s+(.*)$/.exec(line)
|
|
26
|
+
if (!match) return null
|
|
27
|
+
const level = match[1]?.length ?? 0
|
|
28
|
+
const word = canonicalWord(stripTrailingColon(match[2] ?? ''))
|
|
29
|
+
return word ? { level, word } : null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A standalone bold line used as a heading: `**Sources:**`, `__References__`. */
|
|
33
|
+
function matchBoldBoundary(line: string): { word: string } | null {
|
|
34
|
+
const match = /^\s*(\*\*|__)(.+?)\1\s*$/.exec(line)
|
|
35
|
+
if (!match) return null
|
|
36
|
+
const word = canonicalWord(stripTrailingColon(match[2] ?? ''))
|
|
37
|
+
return word ? { word } : null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function stripTrailingColon(value: string): string {
|
|
41
|
+
return value.replace(/\s*:\s*$/, '')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Returns the canonical capitalisation, or null when this is not a boundary. */
|
|
45
|
+
function canonicalWord(rawText: string): string | null {
|
|
46
|
+
const visible = rawText.replaceAll(NBSP, ' ').replace(/[*_]/g, '').trim().toLowerCase()
|
|
47
|
+
const found = SECTION_WORDS.find((candidate) => candidate === visible)
|
|
48
|
+
return found ? `${found[0]?.toUpperCase()}${found.slice(1)}` : null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A line whose visible content is empty once emphasis and spacing are removed. */
|
|
52
|
+
function isFormattingOnly(line: string): boolean {
|
|
53
|
+
if (line.trim() === '') return false // a real blank line is structural
|
|
54
|
+
return (
|
|
55
|
+
line
|
|
56
|
+
.replaceAll(NBSP, ' ')
|
|
57
|
+
.replace(/[*_~`]/g, '')
|
|
58
|
+
.trim() === ''
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function headingLevel(line: string): number | null {
|
|
63
|
+
const match = /^(#{1,6})\s+\S/.exec(line)
|
|
64
|
+
return match ? (match[1]?.length ?? null) : null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Unwrap emphasis that covers an ENTIRE link label: `[**t**](u)` -> `[t](u)`.
|
|
69
|
+
*
|
|
70
|
+
* Partial emphasis (`[a **b** c](u)`) is intentional and preserved, and an
|
|
71
|
+
* unbalanced marker (`[**a](u)`) is ambiguous — repairing it would be guessing
|
|
72
|
+
* at intent. The URL is never touched.
|
|
73
|
+
*/
|
|
74
|
+
function unwrapWholeLabelEmphasis(line: string): string {
|
|
75
|
+
return line.replace(/\[([^\]]*)\]\(/g, (whole, label: string) => {
|
|
76
|
+
const match = /^(\*\*|__|\*|_)([\s\S]+)\1$/.exec(label.trim())
|
|
77
|
+
if (!match) return whole
|
|
78
|
+
const inner = match[2] ?? ''
|
|
79
|
+
// Only unwrap when the delimiter genuinely wraps the whole label rather
|
|
80
|
+
// than being two adjacent runs, e.g. `**a** and **b**`.
|
|
81
|
+
if (inner.includes(match[1] as string)) return whole
|
|
82
|
+
return `[${inner}](`
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function normalizeSectionLine(line: string): string {
|
|
87
|
+
let next = line
|
|
88
|
+
|
|
89
|
+
// Manual Unicode bullets become real list markers.
|
|
90
|
+
next = next.replace(/^(\s*)[●•▪]\s*/u, '$1- ')
|
|
91
|
+
|
|
92
|
+
// Non-breaking-space indentation after a list marker collapses to one space.
|
|
93
|
+
next = next.replace(
|
|
94
|
+
new RegExp(`^(\\s*[-*+])[ ${NBSP}]+`, 'u'),
|
|
95
|
+
(_m, marker: string) => `${marker} `,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
next = unwrapWholeLabelEmphasis(next)
|
|
99
|
+
return next
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Rewrite `Sources` / `References` sections to the canonical shape.
|
|
104
|
+
*
|
|
105
|
+
* Only lines inside a bounded section are touched. A section starts at an ATX
|
|
106
|
+
* heading or a standalone bold line whose visible text is `Sources` or
|
|
107
|
+
* `References` (with an optional trailing colon), and ends at the next heading
|
|
108
|
+
* of the same or higher level, or at end of document.
|
|
109
|
+
*
|
|
110
|
+
* Deliberately does NOT rewrite URLs, invent titles for bare URLs, repair
|
|
111
|
+
* unbalanced emphasis, or alter anything outside a section — each of those
|
|
112
|
+
* requires guessing at editorial intent.
|
|
113
|
+
*/
|
|
114
|
+
export function normalizeSourceSections(markdown: string): SourceSectionNormalization {
|
|
115
|
+
const usesCrlf = markdown.includes('\r\n')
|
|
116
|
+
const lines = markdown.replace(/\r\n/g, '\n').split('\n')
|
|
117
|
+
const out: string[] = []
|
|
118
|
+
|
|
119
|
+
let sectionLevel: number | null = null
|
|
120
|
+
|
|
121
|
+
for (const line of lines) {
|
|
122
|
+
if (sectionLevel !== null) {
|
|
123
|
+
const level = headingLevel(line)
|
|
124
|
+
if (level !== null && level <= sectionLevel) {
|
|
125
|
+
sectionLevel = null // section closed; fall through to boundary checks
|
|
126
|
+
} else {
|
|
127
|
+
if (isFormattingOnly(line)) continue
|
|
128
|
+
out.push(normalizeSectionLine(line))
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const heading = matchHeadingBoundary(line)
|
|
134
|
+
if (heading) {
|
|
135
|
+
sectionLevel = heading.level
|
|
136
|
+
out.push(`${'#'.repeat(heading.level)} ${heading.word}`)
|
|
137
|
+
continue
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const bold = matchBoldBoundary(line)
|
|
141
|
+
if (bold) {
|
|
142
|
+
sectionLevel = 2
|
|
143
|
+
out.push(`## ${bold.word}`)
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
out.push(line)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const joined = out.join('\n')
|
|
151
|
+
const result = usesCrlf ? joined.replace(/\n/g, '\r\n') : joined
|
|
152
|
+
return { markdown: result, changed: result !== markdown }
|
|
153
|
+
}
|