@valkyrianlabs/payload-markdown-docs 0.1.0-canary.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 +195 -0
- package/dist/admin/DocsSetManager.d.ts +2 -0
- package/dist/admin/DocsSetManager.js +298 -0
- package/dist/admin/DocsSetManager.js.map +1 -0
- package/dist/admin/docsSetManagerData.d.ts +25 -0
- package/dist/admin/docsSetManagerData.js +266 -0
- package/dist/admin/docsSetManagerData.js.map +1 -0
- package/dist/admin/docsSetManagerTypes.d.ts +103 -0
- package/dist/admin/docsSetManagerTypes.js +3 -0
- package/dist/admin/docsSetManagerTypes.js.map +1 -0
- package/dist/admin/index.d.ts +3 -0
- package/dist/admin/index.js +4 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/cli/commands/install.d.ts +2 -0
- package/dist/cli/commands/install.js +211 -0
- package/dist/cli/commands/install.js.map +1 -0
- package/dist/cli/commands/keygen.d.ts +2 -0
- package/dist/cli/commands/keygen.js +89 -0
- package/dist/cli/commands/keygen.js.map +1 -0
- package/dist/cli/commands/manifest.d.ts +2 -0
- package/dist/cli/commands/manifest.js +50 -0
- package/dist/cli/commands/manifest.js.map +1 -0
- package/dist/cli/commands/plan.d.ts +2 -0
- package/dist/cli/commands/plan.js +110 -0
- package/dist/cli/commands/plan.js.map +1 -0
- package/dist/cli/commands/push.d.ts +3 -0
- package/dist/cli/commands/push.js +308 -0
- package/dist/cli/commands/push.js.map +1 -0
- package/dist/cli/commands/validate.d.ts +3 -0
- package/dist/cli/commands/validate.js +109 -0
- package/dist/cli/commands/validate.js.map +1 -0
- package/dist/cli/filesystem.d.ts +20 -0
- package/dist/cli/filesystem.js +96 -0
- package/dist/cli/filesystem.js.map +1 -0
- package/dist/cli/format.d.ts +35 -0
- package/dist/cli/format.js +76 -0
- package/dist/cli/format.js.map +1 -0
- package/dist/cli/http.d.ts +19 -0
- package/dist/cli/http.js +39 -0
- package/dist/cli/http.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.js +214 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/parseArgs.d.ts +5 -0
- package/dist/cli/parseArgs.js +219 -0
- package/dist/cli/parseArgs.js.map +1 -0
- package/dist/cli/types.d.ts +51 -0
- package/dist/cli/types.js +3 -0
- package/dist/cli/types.js.map +1 -0
- package/dist/collections/docs.d.ts +9 -0
- package/dist/collections/docs.js +168 -0
- package/dist/collections/docs.js.map +1 -0
- package/dist/collections/docsGroups.d.ts +5 -0
- package/dist/collections/docsGroups.js +57 -0
- package/dist/collections/docsGroups.js.map +1 -0
- package/dist/collections/docsSets.d.ts +8 -0
- package/dist/collections/docsSets.js +158 -0
- package/dist/collections/docsSets.js.map +1 -0
- package/dist/collections/index.d.ts +10 -0
- package/dist/collections/index.js +7 -0
- package/dist/collections/index.js.map +1 -0
- package/dist/collections/nonces.d.ts +6 -0
- package/dist/collections/nonces.js +57 -0
- package/dist/collections/nonces.js.map +1 -0
- package/dist/collections/syncRuns.d.ts +5 -0
- package/dist/collections/syncRuns.js +139 -0
- package/dist/collections/syncRuns.js.map +1 -0
- package/dist/constants.d.ts +21 -0
- package/dist/constants.js +23 -0
- package/dist/constants.js.map +1 -0
- package/dist/endpoints/index.d.ts +2 -0
- package/dist/endpoints/index.js +3 -0
- package/dist/endpoints/index.js.map +1 -0
- package/dist/endpoints/sync.d.ts +47 -0
- package/dist/endpoints/sync.js +616 -0
- package/dist/endpoints/sync.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/next/PayloadMarkdownDocsPage.d.ts +7 -0
- package/dist/next/PayloadMarkdownDocsPage.js +142 -0
- package/dist/next/PayloadMarkdownDocsPage.js.map +1 -0
- package/dist/next/index.d.ts +9 -0
- package/dist/next/index.js +7 -0
- package/dist/next/index.js.map +1 -0
- package/dist/next/markdown.d.ts +14 -0
- package/dist/next/markdown.js +232 -0
- package/dist/next/markdown.js.map +1 -0
- package/dist/next/metadata.d.ts +3 -0
- package/dist/next/metadata.js +33 -0
- package/dist/next/metadata.js.map +1 -0
- package/dist/next/records.d.ts +14 -0
- package/dist/next/records.js +146 -0
- package/dist/next/records.js.map +1 -0
- package/dist/next/route.d.ts +6 -0
- package/dist/next/route.js +271 -0
- package/dist/next/route.js.map +1 -0
- package/dist/next/sidebar.d.ts +15 -0
- package/dist/next/sidebar.js +137 -0
- package/dist/next/sidebar.js.map +1 -0
- package/dist/next/types.d.ts +117 -0
- package/dist/next/types.js +3 -0
- package/dist/next/types.js.map +1 -0
- package/dist/payload/applyDocsSync.d.ts +54 -0
- package/dist/payload/applyDocsSync.js +176 -0
- package/dist/payload/applyDocsSync.js.map +1 -0
- package/dist/payload/docsConflicts.d.ts +12 -0
- package/dist/payload/docsConflicts.js +34 -0
- package/dist/payload/docsConflicts.js.map +1 -0
- package/dist/payload/docsData.d.ts +23 -0
- package/dist/payload/docsData.js +59 -0
- package/dist/payload/docsData.js.map +1 -0
- package/dist/payload/docsSets.d.ts +38 -0
- package/dist/payload/docsSets.js +57 -0
- package/dist/payload/docsSets.js.map +1 -0
- package/dist/payload/existingDocs.d.ts +43 -0
- package/dist/payload/existingDocs.js +97 -0
- package/dist/payload/existingDocs.js.map +1 -0
- package/dist/payload/index.d.ts +15 -0
- package/dist/payload/index.js +10 -0
- package/dist/payload/index.js.map +1 -0
- package/dist/payload/routeCollisions.d.ts +31 -0
- package/dist/payload/routeCollisions.js +104 -0
- package/dist/payload/routeCollisions.js.map +1 -0
- package/dist/payload/syncRuns.d.ts +60 -0
- package/dist/payload/syncRuns.js +53 -0
- package/dist/payload/syncRuns.js.map +1 -0
- package/dist/plugin.d.ts +3 -0
- package/dist/plugin.js +165 -0
- package/dist/plugin.js.map +1 -0
- package/dist/routing/index.d.ts +3 -0
- package/dist/routing/index.js +4 -0
- package/dist/routing/index.js.map +1 -0
- package/dist/routing/paths.d.ts +7 -0
- package/dist/routing/paths.js +23 -0
- package/dist/routing/paths.js.map +1 -0
- package/dist/routing/reservations.d.ts +37 -0
- package/dist/routing/reservations.js +79 -0
- package/dist/routing/reservations.js.map +1 -0
- package/dist/security/canonical.d.ts +12 -0
- package/dist/security/canonical.js +24 -0
- package/dist/security/canonical.js.map +1 -0
- package/dist/security/githubOidc.d.ts +45 -0
- package/dist/security/githubOidc.js +177 -0
- package/dist/security/githubOidc.js.map +1 -0
- package/dist/security/headers.d.ts +22 -0
- package/dist/security/headers.js +44 -0
- package/dist/security/headers.js.map +1 -0
- package/dist/security/index.d.ts +15 -0
- package/dist/security/index.js +9 -0
- package/dist/security/index.js.map +1 -0
- package/dist/security/jwks.d.ts +20 -0
- package/dist/security/jwks.js +40 -0
- package/dist/security/jwks.js.map +1 -0
- package/dist/security/jwt.d.ts +10 -0
- package/dist/security/jwt.js +42 -0
- package/dist/security/jwt.js.map +1 -0
- package/dist/security/nonce.d.ts +34 -0
- package/dist/security/nonce.js +43 -0
- package/dist/security/nonce.js.map +1 -0
- package/dist/security/sign.d.ts +13 -0
- package/dist/security/sign.js +39 -0
- package/dist/security/sign.js.map +1 -0
- package/dist/security/verify.d.ts +28 -0
- package/dist/security/verify.js +54 -0
- package/dist/security/verify.js.map +1 -0
- package/dist/skills/codex/SKILL.md +173 -0
- package/dist/skills/codex/examples/docs-page.md +42 -0
- package/dist/skills/codex/examples/github-actions.md +64 -0
- package/dist/skills/codex/reference/admin.md +28 -0
- package/dist/skills/codex/reference/frontmatter.md +39 -0
- package/dist/skills/codex/reference/payload-markdown-directives.md +77 -0
- package/dist/skills/codex/reference/routing.md +35 -0
- package/dist/skills/codex/reference/sync.md +35 -0
- package/dist/skills/codex/reference/troubleshooting.md +53 -0
- package/dist/skills/codex/reference/workflow.md +39 -0
- package/dist/sync/aiExportManifest.d.ts +58 -0
- package/dist/sync/aiExportManifest.js +430 -0
- package/dist/sync/aiExportManifest.js.map +1 -0
- package/dist/sync/frontmatter.d.ts +28 -0
- package/dist/sync/frontmatter.js +210 -0
- package/dist/sync/frontmatter.js.map +1 -0
- package/dist/sync/hash.d.ts +1 -0
- package/dist/sync/hash.js +8 -0
- package/dist/sync/hash.js.map +1 -0
- package/dist/sync/index.d.ts +12 -0
- package/dist/sync/index.js +9 -0
- package/dist/sync/index.js.map +1 -0
- package/dist/sync/manifest.d.ts +58 -0
- package/dist/sync/manifest.js +21 -0
- package/dist/sync/manifest.js.map +1 -0
- package/dist/sync/paths.d.ts +16 -0
- package/dist/sync/paths.js +116 -0
- package/dist/sync/paths.js.map +1 -0
- package/dist/sync/plan.d.ts +29 -0
- package/dist/sync/plan.js +72 -0
- package/dist/sync/plan.js.map +1 -0
- package/dist/sync/validate.d.ts +26 -0
- package/dist/sync/validate.js +308 -0
- package/dist/sync/validate.js.map +1 -0
- package/dist/types.d.ts +84 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +143 -0
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
import { DEFAULT_DOCS_ROUTE_BASE, DEFAULT_MAX_BODY_BYTES, DEFAULT_MAX_SKEW_SECONDS, DEFAULT_NONCE_TTL_SECONDS } from '../constants.js';
|
|
2
|
+
import { applyDocsSync, assertApplyDeleteBehaviorSupported, createSyncRunAudit, findConfiguredPagesRouteCollisions, findDocsSetBySourceId, findDocsSyncConflicts, findDuplicateDesiredRouteCollisions, findExistingDocsRouteCollisions, findExistingPayloadDocsRecords, getRecordId, toExistingDocsRecord, updateDocsSetAfterSync, updateSyncRunAudit } from '../payload/index.js';
|
|
3
|
+
import { assertNonceNotReplayed, buildCanonicalSigningString, extractSyncRequestHeaders, getCanonicalPathFromRequestUrl, storeAcceptedNonce, validateTimestampSkew, verifyBodySha256, verifyEd25519Signature, verifyGitHubOidcToken } from '../security/index.js';
|
|
4
|
+
import { planDocsSync, validateDocsManifest } from '../sync/index.js';
|
|
5
|
+
const jsonResponse = (body, status = 200)=>Response.json(body, {
|
|
6
|
+
status
|
|
7
|
+
});
|
|
8
|
+
const errorResponse = (code, message, status = 400, extras = {})=>jsonResponse({
|
|
9
|
+
...extras,
|
|
10
|
+
error: {
|
|
11
|
+
code,
|
|
12
|
+
message
|
|
13
|
+
},
|
|
14
|
+
ok: false
|
|
15
|
+
}, status);
|
|
16
|
+
const isRecord = (value)=>typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
17
|
+
const parseManifestBody = (rawBody)=>{
|
|
18
|
+
try {
|
|
19
|
+
const parsed = JSON.parse(rawBody);
|
|
20
|
+
return isRecord(parsed) ? parsed : undefined;
|
|
21
|
+
} catch {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
const findSourceConfig = (sourceId, sources)=>sources?.find((source)=>source.id === sourceId);
|
|
26
|
+
const getAllowedSourceIds = (sources)=>{
|
|
27
|
+
if (!sources || sources.length === 0) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
return sources.map((source)=>source.id);
|
|
31
|
+
};
|
|
32
|
+
const resolveSyncSource = async ({ manifest, options, payload })=>{
|
|
33
|
+
const sourceId = manifest.source?.id;
|
|
34
|
+
if (!sourceId) {
|
|
35
|
+
return {
|
|
36
|
+
source: {
|
|
37
|
+
allowedSourceIds: getAllowedSourceIds(options.sources),
|
|
38
|
+
routeBase: options.routeBase ?? DEFAULT_DOCS_ROUTE_BASE
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const docsSet = options.docsSetsEnabled ? await findDocsSetBySourceId({
|
|
43
|
+
collectionSlug: options.docsSetsCollectionSlug,
|
|
44
|
+
payload,
|
|
45
|
+
sourceId
|
|
46
|
+
}) : undefined;
|
|
47
|
+
if (docsSet) {
|
|
48
|
+
if (docsSet.sourceRoot && manifest.source.root && docsSet.sourceRoot !== manifest.source.root) {
|
|
49
|
+
return {
|
|
50
|
+
response: errorResponse('source_not_allowed', `Manifest source.root "${manifest.source.root}" is not allowed for docs set source "${sourceId}".`, 400)
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
source: {
|
|
55
|
+
allowedSourceIds: [
|
|
56
|
+
sourceId
|
|
57
|
+
],
|
|
58
|
+
docsSet,
|
|
59
|
+
routeBase: docsSet.routeBase,
|
|
60
|
+
sourceRoot: docsSet.sourceRoot
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const sourceConfig = findSourceConfig(sourceId, options.sources);
|
|
65
|
+
if (options.sources && options.sources.length > 0 && !sourceConfig) {
|
|
66
|
+
return {
|
|
67
|
+
response: errorResponse('source_not_allowed', `Manifest source.id "${sourceId}" is not configured for this endpoint.`, 400)
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
if (sourceConfig?.root && manifest.source.root && sourceConfig.root !== manifest.source.root) {
|
|
71
|
+
return {
|
|
72
|
+
response: errorResponse('source_not_allowed', `Manifest source.root "${manifest.source.root}" is not allowed for source "${sourceId}".`, 400)
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
source: {
|
|
77
|
+
allowedSourceIds: getAllowedSourceIds(options.sources),
|
|
78
|
+
routeBase: sourceConfig?.routeBase ?? options.routeBase ?? DEFAULT_DOCS_ROUTE_BASE,
|
|
79
|
+
sourceRoot: sourceConfig?.root
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
const summarizePlan = (plan)=>({
|
|
84
|
+
archive: plan.archive.length,
|
|
85
|
+
create: plan.create.length,
|
|
86
|
+
delete: plan.delete.length,
|
|
87
|
+
draft: plan.draft.length,
|
|
88
|
+
unchanged: plan.unchanged.length,
|
|
89
|
+
update: plan.update.length,
|
|
90
|
+
warnings: plan.warnings.length
|
|
91
|
+
});
|
|
92
|
+
const serializeChange = (change)=>({
|
|
93
|
+
current: change.current ? {
|
|
94
|
+
archived: change.current.archived,
|
|
95
|
+
route: change.current.route,
|
|
96
|
+
sourceHash: change.current.sourceHash,
|
|
97
|
+
title: change.current.title
|
|
98
|
+
} : undefined,
|
|
99
|
+
desired: change.desired ? {
|
|
100
|
+
route: change.desired.route,
|
|
101
|
+
sha256: change.desired.sha256,
|
|
102
|
+
title: change.desired.title
|
|
103
|
+
} : undefined,
|
|
104
|
+
reason: change.reason,
|
|
105
|
+
sourcePath: change.sourcePath
|
|
106
|
+
});
|
|
107
|
+
const serializeChanges = (plan)=>({
|
|
108
|
+
archive: plan.archive.map(serializeChange),
|
|
109
|
+
create: plan.create.map(serializeChange),
|
|
110
|
+
delete: plan.delete.map(serializeChange),
|
|
111
|
+
draft: plan.draft.map(serializeChange),
|
|
112
|
+
unchanged: plan.unchanged.map(serializeChange),
|
|
113
|
+
update: plan.update.map(serializeChange)
|
|
114
|
+
});
|
|
115
|
+
const getTotalManifestBytes = (manifest)=>manifest.files.reduce((total, file)=>total + Buffer.byteLength(file.content, 'utf8'), 0);
|
|
116
|
+
const getPlannedConflictChanges = ({ existing, plan })=>{
|
|
117
|
+
const existingBySourcePath = new Map(existing.map((record)=>[
|
|
118
|
+
record.sourcePath,
|
|
119
|
+
record
|
|
120
|
+
]));
|
|
121
|
+
const archivedUnchanged = plan.unchanged.filter((change)=>{
|
|
122
|
+
const current = existingBySourcePath.get(change.sourcePath);
|
|
123
|
+
return current?.archived === true;
|
|
124
|
+
});
|
|
125
|
+
return [
|
|
126
|
+
...plan.update,
|
|
127
|
+
...plan.archive,
|
|
128
|
+
...plan.draft,
|
|
129
|
+
...plan.delete,
|
|
130
|
+
...archivedUnchanged
|
|
131
|
+
];
|
|
132
|
+
};
|
|
133
|
+
const getDefaultPublishMode = (options)=>options.defaultPublishMode ?? (options.docsEnableDrafts ? 'draft' : 'preserve');
|
|
134
|
+
const getLifecyclePolicyError = ({ deleteBehavior, manifest, options, publishMode })=>{
|
|
135
|
+
if (manifest.publish && options.allowPublish !== true) {
|
|
136
|
+
return errorResponse('publish_disabled', 'Publishing is disabled by server configuration.', 403);
|
|
137
|
+
}
|
|
138
|
+
if ((manifest.publish || publishMode === 'published') && !options.docsEnableDrafts) {
|
|
139
|
+
return errorResponse('publish_not_available', 'Publishing requires a draft-enabled dedicated docs collection.', 400);
|
|
140
|
+
}
|
|
141
|
+
if (publishMode === 'published' && options.allowPublish !== true) {
|
|
142
|
+
return errorResponse('publish_disabled', 'Publishing is disabled by server configuration.', 403);
|
|
143
|
+
}
|
|
144
|
+
if (publishMode === 'draft' && !options.docsEnableDrafts) {
|
|
145
|
+
return errorResponse('draft_behavior_not_available', 'Draft mode requires a draft-enabled dedicated docs collection.', 400);
|
|
146
|
+
}
|
|
147
|
+
if (deleteBehavior === 'draft' && !options.docsEnableDrafts) {
|
|
148
|
+
return errorResponse('draft_behavior_not_available', 'Draft delete behavior requires a draft-enabled dedicated docs collection.', 400);
|
|
149
|
+
}
|
|
150
|
+
if (deleteBehavior === 'delete' && options.allowHardDelete !== true) {
|
|
151
|
+
return errorResponse('hard_delete_disabled', 'Hard delete is disabled by server configuration.', 403);
|
|
152
|
+
}
|
|
153
|
+
return undefined;
|
|
154
|
+
};
|
|
155
|
+
const getRouteCollisionIssues = async ({ docsSet, manifest, options, payload, routeBase })=>{
|
|
156
|
+
const desiredRoutes = manifest.files.map((file)=>file.route);
|
|
157
|
+
const duplicateDesiredRouteCollisions = findDuplicateDesiredRouteCollisions(desiredRoutes);
|
|
158
|
+
const existingDocsRouteCollisions = options.docsEnabled ? await findExistingDocsRouteCollisions({
|
|
159
|
+
collectionSlug: options.docsCollectionSlug,
|
|
160
|
+
docsSetId: docsSet?.id,
|
|
161
|
+
payload,
|
|
162
|
+
routes: desiredRoutes,
|
|
163
|
+
sourceId: manifest.source.id
|
|
164
|
+
}) : [];
|
|
165
|
+
const pageRouteCollisions = options.routing?.pages?.enabled === true ? await findConfiguredPagesRouteCollisions({
|
|
166
|
+
allowBridgePages: options.routing.pages.allowBridgePages,
|
|
167
|
+
bridgeField: options.routing.pages.bridgeField,
|
|
168
|
+
collectionSlug: options.routing.pages.collection,
|
|
169
|
+
docsSetRouteBase: routeBase,
|
|
170
|
+
payload,
|
|
171
|
+
routeField: options.routing.pages.routeField
|
|
172
|
+
}) : [];
|
|
173
|
+
return [
|
|
174
|
+
...duplicateDesiredRouteCollisions,
|
|
175
|
+
...existingDocsRouteCollisions,
|
|
176
|
+
...pageRouteCollisions
|
|
177
|
+
];
|
|
178
|
+
};
|
|
179
|
+
const getRequiredHeader = (headers, name)=>{
|
|
180
|
+
const value = headers.get(name);
|
|
181
|
+
return value && value.trim() !== '' ? value.trim() : undefined;
|
|
182
|
+
};
|
|
183
|
+
const getBearerToken = (headers)=>{
|
|
184
|
+
const authorization = getRequiredHeader(headers, 'authorization');
|
|
185
|
+
if (!authorization) {
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
const [scheme, token] = authorization.split(/\s+/, 2);
|
|
189
|
+
if (scheme?.toLowerCase() !== 'bearer' || !token) {
|
|
190
|
+
return '';
|
|
191
|
+
}
|
|
192
|
+
return token;
|
|
193
|
+
};
|
|
194
|
+
const assertReplayProtectionAvailable = (options)=>options.noncesEnabled ? undefined : errorResponse('replay_protection_unavailable', 'Sync endpoint requires nonce replay protection.', 500);
|
|
195
|
+
const authenticateEd25519Request = async ({ now, options, rawBody, req })=>{
|
|
196
|
+
if (!options.auth || options.auth.mode !== 'ed25519') {
|
|
197
|
+
return {
|
|
198
|
+
response: errorResponse('auth_disabled', 'Signed sync authentication is not configured for this endpoint.', 401)
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
const headersResult = extractSyncRequestHeaders(req.headers);
|
|
202
|
+
if (!headersResult.ok) {
|
|
203
|
+
return {
|
|
204
|
+
response: errorResponse('missing_header', `Missing required sync header: ${headersResult.header}.`, 401)
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
const keyConfig = options.auth.keys.find((key)=>key.id === headersResult.headers.keyId);
|
|
208
|
+
if (!keyConfig) {
|
|
209
|
+
return {
|
|
210
|
+
response: errorResponse('unknown_key', 'Unknown sync request key id.', 401)
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
const bodyHash = verifyBodySha256({
|
|
214
|
+
body: rawBody,
|
|
215
|
+
expectedHash: headersResult.headers.bodySha256
|
|
216
|
+
});
|
|
217
|
+
if (!bodyHash.ok) {
|
|
218
|
+
return {
|
|
219
|
+
response: errorResponse('body_hash_mismatch', 'Sync request body hash does not match the signed header.', 401)
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
const timestampValidation = validateTimestampSkew({
|
|
223
|
+
maxSkewSeconds: options.auth.maxSkewSeconds ?? options.maxSkewSeconds ?? DEFAULT_MAX_SKEW_SECONDS,
|
|
224
|
+
now,
|
|
225
|
+
timestamp: headersResult.headers.timestamp
|
|
226
|
+
});
|
|
227
|
+
if (!timestampValidation.ok) {
|
|
228
|
+
return {
|
|
229
|
+
response: errorResponse('invalid_timestamp', timestampValidation.message, 401)
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
const replayUnavailable = assertReplayProtectionAvailable(options);
|
|
233
|
+
if (replayUnavailable) {
|
|
234
|
+
return {
|
|
235
|
+
response: replayUnavailable
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
const nonceAvailable = await assertNonceNotReplayed({
|
|
239
|
+
collectionSlug: options.noncesCollectionSlug,
|
|
240
|
+
keyId: headersResult.headers.keyId,
|
|
241
|
+
nonce: headersResult.headers.nonce,
|
|
242
|
+
now,
|
|
243
|
+
payload: req.payload
|
|
244
|
+
});
|
|
245
|
+
if (!nonceAvailable) {
|
|
246
|
+
return {
|
|
247
|
+
response: errorResponse('nonce_replay', 'Sync request nonce has already been used.', 409)
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
const canonicalPath = getCanonicalPathFromRequestUrl({
|
|
251
|
+
endpointPath: options.endpointPath,
|
|
252
|
+
url: req.url
|
|
253
|
+
});
|
|
254
|
+
const canonicalString = buildCanonicalSigningString({
|
|
255
|
+
bodySha256: bodyHash.computedHash,
|
|
256
|
+
method: 'POST',
|
|
257
|
+
nonce: headersResult.headers.nonce,
|
|
258
|
+
path: canonicalPath,
|
|
259
|
+
timestamp: headersResult.headers.timestamp
|
|
260
|
+
});
|
|
261
|
+
if (!verifyEd25519Signature({
|
|
262
|
+
canonicalString,
|
|
263
|
+
publicKey: keyConfig.publicKey,
|
|
264
|
+
signature: headersResult.headers.signature
|
|
265
|
+
})) {
|
|
266
|
+
return {
|
|
267
|
+
response: errorResponse('invalid_signature', 'Invalid sync request signature.', 401)
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
const nonceTtlSeconds = options.auth.nonceTtlSeconds ?? options.nonceTtlSeconds ?? DEFAULT_NONCE_TTL_SECONDS;
|
|
271
|
+
return {
|
|
272
|
+
identity: {
|
|
273
|
+
bodyHash: bodyHash.computedHash,
|
|
274
|
+
expiresAt: new Date(now.getTime() + nonceTtlSeconds * 1000),
|
|
275
|
+
keyId: headersResult.headers.keyId,
|
|
276
|
+
nonce: headersResult.headers.nonce
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
};
|
|
280
|
+
const authenticateGitHubOidcRequest = async ({ now, options, rawBody, req })=>{
|
|
281
|
+
if (!options.auth || options.auth.mode !== 'github-oidc') {
|
|
282
|
+
return {
|
|
283
|
+
response: errorResponse('auth_disabled', 'GitHub OIDC sync authentication is not configured for this endpoint.', 401)
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
const token = getBearerToken(req.headers);
|
|
287
|
+
if (token === undefined) {
|
|
288
|
+
return {
|
|
289
|
+
response: errorResponse('missing_header', 'Missing required sync header: Authorization.', 401)
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
if (token === '') {
|
|
293
|
+
return {
|
|
294
|
+
response: errorResponse('oidc_invalid_token', 'Authorization must be a Bearer GitHub OIDC token.', 401)
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
const expectedHash = getRequiredHeader(req.headers, 'x-vl-md-docs-body-sha256');
|
|
298
|
+
if (!expectedHash) {
|
|
299
|
+
return {
|
|
300
|
+
response: errorResponse('missing_header', 'Missing required sync header: X-VL-MD-DOCS-Body-SHA256.', 401)
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
const bodyHash = verifyBodySha256({
|
|
304
|
+
body: rawBody,
|
|
305
|
+
expectedHash
|
|
306
|
+
});
|
|
307
|
+
if (!bodyHash.ok) {
|
|
308
|
+
return {
|
|
309
|
+
response: errorResponse('body_hash_mismatch', 'Sync request body hash does not match the OIDC header.', 401)
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
const verified = await verifyGitHubOidcToken({
|
|
313
|
+
config: options.auth,
|
|
314
|
+
fetchJson: options.oidcFetchJson,
|
|
315
|
+
now,
|
|
316
|
+
token
|
|
317
|
+
});
|
|
318
|
+
if (!verified.ok) {
|
|
319
|
+
return {
|
|
320
|
+
response: errorResponse(verified.code, verified.message, verified.code === 'oidc_jwks_unavailable' ? 503 : 401)
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
const replayUnavailable = assertReplayProtectionAvailable(options);
|
|
324
|
+
if (replayUnavailable) {
|
|
325
|
+
return {
|
|
326
|
+
response: replayUnavailable
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
const nonceAvailable = await assertNonceNotReplayed({
|
|
330
|
+
collectionSlug: options.noncesCollectionSlug,
|
|
331
|
+
keyId: verified.token.keyId,
|
|
332
|
+
nonce: verified.token.claims.jti,
|
|
333
|
+
now,
|
|
334
|
+
payload: req.payload
|
|
335
|
+
});
|
|
336
|
+
if (!nonceAvailable) {
|
|
337
|
+
return {
|
|
338
|
+
response: errorResponse('oidc_replay', 'GitHub OIDC token jti has already been used.', 409)
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
return {
|
|
342
|
+
identity: {
|
|
343
|
+
actor: verified.token.claims.actor,
|
|
344
|
+
bodyHash: bodyHash.computedHash,
|
|
345
|
+
branch: verified.token.claims.ref,
|
|
346
|
+
commit: verified.token.claims.sha,
|
|
347
|
+
expiresAt: verified.token.expiresAt,
|
|
348
|
+
keyId: verified.token.keyId,
|
|
349
|
+
nonce: verified.token.claims.jti,
|
|
350
|
+
repository: verified.token.claims.repository
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
};
|
|
354
|
+
const authenticateSyncRequest = async ({ now, options, rawBody, req })=>{
|
|
355
|
+
if (!options.auth || options.auth.mode === 'disabled') {
|
|
356
|
+
return {
|
|
357
|
+
response: errorResponse('auth_disabled', 'Sync authentication is not configured for this endpoint.', 401)
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
if (options.auth.mode === 'github-oidc') {
|
|
361
|
+
return authenticateGitHubOidcRequest({
|
|
362
|
+
now,
|
|
363
|
+
options,
|
|
364
|
+
rawBody,
|
|
365
|
+
req
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
return authenticateEd25519Request({
|
|
369
|
+
now,
|
|
370
|
+
options,
|
|
371
|
+
rawBody,
|
|
372
|
+
req
|
|
373
|
+
});
|
|
374
|
+
};
|
|
375
|
+
const createSyncEndpointHandler = (options)=>async (req)=>{
|
|
376
|
+
const startedAt = options.getNow?.() ?? new Date();
|
|
377
|
+
if (req.method && req.method.toUpperCase() !== 'POST') {
|
|
378
|
+
return errorResponse('invalid_method', 'Sync endpoint only accepts POST.', 405);
|
|
379
|
+
}
|
|
380
|
+
if (typeof req.text !== 'function') {
|
|
381
|
+
return errorResponse('invalid_body', 'Sync endpoint requires access to the request body text.', 400);
|
|
382
|
+
}
|
|
383
|
+
const rawBody = await req.text();
|
|
384
|
+
const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
|
385
|
+
if (Buffer.byteLength(rawBody, 'utf8') > maxBodyBytes) {
|
|
386
|
+
return errorResponse('invalid_body', 'Sync request body is too large.', 413);
|
|
387
|
+
}
|
|
388
|
+
const authentication = await authenticateSyncRequest({
|
|
389
|
+
now: startedAt,
|
|
390
|
+
options,
|
|
391
|
+
rawBody,
|
|
392
|
+
req
|
|
393
|
+
});
|
|
394
|
+
if (authentication.response) {
|
|
395
|
+
return authentication.response;
|
|
396
|
+
}
|
|
397
|
+
const manifest = parseManifestBody(rawBody);
|
|
398
|
+
if (!manifest) {
|
|
399
|
+
return errorResponse('invalid_body', 'Sync request body must be a JSON manifest.', 400);
|
|
400
|
+
}
|
|
401
|
+
const sourceResolution = await resolveSyncSource({
|
|
402
|
+
manifest,
|
|
403
|
+
options,
|
|
404
|
+
payload: req.payload
|
|
405
|
+
});
|
|
406
|
+
if (sourceResolution.response) {
|
|
407
|
+
return sourceResolution.response;
|
|
408
|
+
}
|
|
409
|
+
const validation = validateDocsManifest(manifest, {
|
|
410
|
+
allowedSourceIds: sourceResolution.source.allowedSourceIds,
|
|
411
|
+
maxTotalBytes: maxBodyBytes,
|
|
412
|
+
routeBase: sourceResolution.source.routeBase
|
|
413
|
+
});
|
|
414
|
+
if (!validation.ok) {
|
|
415
|
+
return jsonResponse({
|
|
416
|
+
error: {
|
|
417
|
+
code: 'invalid_manifest',
|
|
418
|
+
message: 'Sync manifest is invalid.'
|
|
419
|
+
},
|
|
420
|
+
ok: false
|
|
421
|
+
}, 400);
|
|
422
|
+
}
|
|
423
|
+
const effectiveDeleteBehavior = options.deleteBehavior ?? 'archive';
|
|
424
|
+
const effectivePublishMode = validation.data.publish ? 'published' : getDefaultPublishMode(options);
|
|
425
|
+
const lifecyclePolicyError = getLifecyclePolicyError({
|
|
426
|
+
deleteBehavior: effectiveDeleteBehavior,
|
|
427
|
+
manifest: validation.data,
|
|
428
|
+
options,
|
|
429
|
+
publishMode: effectivePublishMode
|
|
430
|
+
});
|
|
431
|
+
if (lifecyclePolicyError) {
|
|
432
|
+
return lifecyclePolicyError;
|
|
433
|
+
}
|
|
434
|
+
const routeCollisions = await getRouteCollisionIssues({
|
|
435
|
+
docsSet: sourceResolution.source.docsSet,
|
|
436
|
+
manifest: validation.data,
|
|
437
|
+
options,
|
|
438
|
+
payload: req.payload,
|
|
439
|
+
routeBase: sourceResolution.source.routeBase
|
|
440
|
+
});
|
|
441
|
+
if (routeCollisions.length > 0) {
|
|
442
|
+
return errorResponse('route_collision', 'One or more docs routes collide with an existing route reservation.', 409, {
|
|
443
|
+
routeCollisions
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
const isSyncMode = validation.data.mode === 'sync';
|
|
447
|
+
if (isSyncMode && options.allowWrites !== true) {
|
|
448
|
+
return errorResponse('sync_writes_disabled', 'Sync writes are disabled by server configuration.', 403);
|
|
449
|
+
}
|
|
450
|
+
if (isSyncMode && options.requireDryRunBeforeApply === true) {
|
|
451
|
+
return errorResponse('dry_run_required_not_implemented', 'Required dry-run proof before apply is not implemented yet.', 400);
|
|
452
|
+
}
|
|
453
|
+
if (isSyncMode && !assertApplyDeleteBehaviorSupported(effectiveDeleteBehavior, {
|
|
454
|
+
allowHardDelete: options.allowHardDelete,
|
|
455
|
+
docsEnableDrafts: options.docsEnableDrafts
|
|
456
|
+
})) {
|
|
457
|
+
return errorResponse('delete_behavior_not_implemented', 'Configured delete behavior cannot be applied.', 400);
|
|
458
|
+
}
|
|
459
|
+
if (isSyncMode && !options.syncRunsEnabled) {
|
|
460
|
+
return errorResponse('audit_unavailable', 'Applied sync requires the sync-run audit collection.', 500);
|
|
461
|
+
}
|
|
462
|
+
const existingPayloadDocs = options.docsEnabled ? await findExistingPayloadDocsRecords({
|
|
463
|
+
collectionSlug: options.docsCollectionSlug,
|
|
464
|
+
docsSetId: sourceResolution.source.docsSet?.id,
|
|
465
|
+
markdownFieldName: options.markdownFieldName,
|
|
466
|
+
payload: req.payload,
|
|
467
|
+
sourceId: validation.data.source.id
|
|
468
|
+
}) : [];
|
|
469
|
+
const existingDocs = existingPayloadDocs.map(toExistingDocsRecord);
|
|
470
|
+
const plan = planDocsSync({
|
|
471
|
+
deleteBehavior: effectiveDeleteBehavior,
|
|
472
|
+
desired: validation.data,
|
|
473
|
+
existing: existingDocs
|
|
474
|
+
});
|
|
475
|
+
const summary = summarizePlan(plan);
|
|
476
|
+
const warnings = [
|
|
477
|
+
...validation.warnings,
|
|
478
|
+
...plan.warnings
|
|
479
|
+
];
|
|
480
|
+
if (isSyncMode) {
|
|
481
|
+
const existingBySourcePath = new Map(existingPayloadDocs.map((record)=>[
|
|
482
|
+
record.sourcePath,
|
|
483
|
+
record
|
|
484
|
+
]));
|
|
485
|
+
const conflicts = findDocsSyncConflicts({
|
|
486
|
+
existingBySourcePath,
|
|
487
|
+
plannedChanges: getPlannedConflictChanges({
|
|
488
|
+
existing: existingPayloadDocs,
|
|
489
|
+
plan
|
|
490
|
+
})
|
|
491
|
+
});
|
|
492
|
+
if (conflicts.length > 0) {
|
|
493
|
+
return errorResponse('manual_edit_conflict', 'One or more docs were modified outside the docs sync workflow.', 409, {
|
|
494
|
+
conflicts
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
await storeAcceptedNonce({
|
|
499
|
+
bodyHash: authentication.identity.bodyHash,
|
|
500
|
+
collectionSlug: options.noncesCollectionSlug,
|
|
501
|
+
expiresAt: authentication.identity.expiresAt,
|
|
502
|
+
keyId: authentication.identity.keyId,
|
|
503
|
+
nonce: authentication.identity.nonce,
|
|
504
|
+
payload: req.payload,
|
|
505
|
+
sourceId: validation.data.source.id,
|
|
506
|
+
usedAt: startedAt
|
|
507
|
+
});
|
|
508
|
+
let syncRunId;
|
|
509
|
+
if (options.syncRunsEnabled) {
|
|
510
|
+
const syncRun = await createSyncRunAudit({
|
|
511
|
+
actor: authentication.identity.actor,
|
|
512
|
+
bodyHash: authentication.identity.bodyHash,
|
|
513
|
+
branch: authentication.identity.branch ?? validation.data.source.branch,
|
|
514
|
+
collectionSlug: options.syncRunsCollectionSlug,
|
|
515
|
+
commit: authentication.identity.commit ?? validation.data.source.commit,
|
|
516
|
+
completedAt: isSyncMode ? startedAt : options.getNow?.() ?? new Date(),
|
|
517
|
+
deleteBehavior: effectiveDeleteBehavior,
|
|
518
|
+
effectivePublishMode,
|
|
519
|
+
errors: [],
|
|
520
|
+
fileCount: validation.data.files.length,
|
|
521
|
+
keyId: authentication.identity.keyId,
|
|
522
|
+
mode: isSyncMode ? 'sync' : 'dry-run',
|
|
523
|
+
payload: req.payload,
|
|
524
|
+
publishRequested: validation.data.publish,
|
|
525
|
+
repository: authentication.identity.repository ?? validation.data.source.repository,
|
|
526
|
+
sourceId: validation.data.source.id,
|
|
527
|
+
startedAt,
|
|
528
|
+
status: isSyncMode ? 'pending' : 'success',
|
|
529
|
+
summary,
|
|
530
|
+
totalBytes: getTotalManifestBytes(validation.data),
|
|
531
|
+
warnings
|
|
532
|
+
});
|
|
533
|
+
syncRunId = getRecordId(syncRun);
|
|
534
|
+
}
|
|
535
|
+
if (isSyncMode) {
|
|
536
|
+
if (!syncRunId) {
|
|
537
|
+
return errorResponse('audit_unavailable', 'Applied sync could not create a sync-run audit record.', 500);
|
|
538
|
+
}
|
|
539
|
+
try {
|
|
540
|
+
const applyResult = await applyDocsSync({
|
|
541
|
+
collectionSlug: options.docsCollectionSlug,
|
|
542
|
+
deleteBehavior: effectiveDeleteBehavior,
|
|
543
|
+
docsEnableDrafts: options.docsEnableDrafts,
|
|
544
|
+
docsSetId: sourceResolution.source.docsSet?.id,
|
|
545
|
+
existing: existingPayloadDocs,
|
|
546
|
+
manifest: validation.data,
|
|
547
|
+
markdownFieldName: options.markdownFieldName,
|
|
548
|
+
now: options.getNow?.() ?? new Date(),
|
|
549
|
+
payload: req.payload,
|
|
550
|
+
plan,
|
|
551
|
+
publishMode: effectivePublishMode,
|
|
552
|
+
syncRunId
|
|
553
|
+
});
|
|
554
|
+
if (!applyResult.ok) {
|
|
555
|
+
return errorResponse('manual_edit_conflict', 'One or more docs were modified outside the docs sync workflow.', 409, {
|
|
556
|
+
conflicts: applyResult.conflicts
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
await updateSyncRunAudit({
|
|
560
|
+
collectionSlug: options.syncRunsCollectionSlug,
|
|
561
|
+
completedAt: options.getNow?.() ?? new Date(),
|
|
562
|
+
payload: req.payload,
|
|
563
|
+
status: 'success',
|
|
564
|
+
summary,
|
|
565
|
+
syncRunId,
|
|
566
|
+
warnings
|
|
567
|
+
});
|
|
568
|
+
if (sourceResolution.source.docsSet) {
|
|
569
|
+
await updateDocsSetAfterSync({
|
|
570
|
+
aiExport: validation.data.aiExport,
|
|
571
|
+
collectionSlug: options.docsSetsCollectionSlug,
|
|
572
|
+
docsCount: validation.data.files.length,
|
|
573
|
+
docsSetId: sourceResolution.source.docsSet.id,
|
|
574
|
+
now: options.getNow?.() ?? new Date(),
|
|
575
|
+
payload: req.payload,
|
|
576
|
+
syncRunId
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
} catch (error) {
|
|
580
|
+
await updateSyncRunAudit({
|
|
581
|
+
collectionSlug: options.syncRunsCollectionSlug,
|
|
582
|
+
completedAt: options.getNow?.() ?? new Date(),
|
|
583
|
+
errors: [
|
|
584
|
+
{
|
|
585
|
+
code: 'invalid_manifest',
|
|
586
|
+
message: error instanceof Error ? error.message : 'Sync apply failed.'
|
|
587
|
+
}
|
|
588
|
+
],
|
|
589
|
+
payload: req.payload,
|
|
590
|
+
status: 'failed',
|
|
591
|
+
summary,
|
|
592
|
+
syncRunId,
|
|
593
|
+
warnings
|
|
594
|
+
});
|
|
595
|
+
return errorResponse('sync_apply_failed', 'Sync apply failed.', 500);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return jsonResponse({
|
|
599
|
+
changes: serializeChanges(plan),
|
|
600
|
+
deleteBehavior: effectiveDeleteBehavior,
|
|
601
|
+
dryRun: !isSyncMode,
|
|
602
|
+
effectivePublishMode,
|
|
603
|
+
ok: true,
|
|
604
|
+
publishRequested: validation.data.publish,
|
|
605
|
+
summary,
|
|
606
|
+
syncRunId: syncRunId === undefined ? undefined : String(syncRunId),
|
|
607
|
+
warnings
|
|
608
|
+
});
|
|
609
|
+
};
|
|
610
|
+
export const createSyncEndpoint = (options)=>({
|
|
611
|
+
handler: createSyncEndpointHandler(options),
|
|
612
|
+
method: 'post',
|
|
613
|
+
path: options.endpointPath
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
//# sourceMappingURL=sync.js.map
|