@xexr/blog-platform 0.2.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.
@@ -0,0 +1,360 @@
1
+ import { type NextRequest, NextResponse } from "next/server";
2
+ import { getStorage } from "@/lib/database";
3
+ import { getBlogPlatformVersion, parseVersionHeader } from "@/lib/version";
4
+ import { requireAuth, authenticateRequest } from "@/lib/auth";
5
+ import { enforceRateLimit, getRateLimitHeaders } from "@/lib/ratelimit";
6
+ import { createCorsResponse, handleCorsOptions } from "@/lib/cors";
7
+ import type {
8
+ PublishPayload,
9
+ PublishResult,
10
+ PublishedArticle,
11
+ } from "@/types/contracts";
12
+
13
+ /**
14
+ * POST /api/blog/publish
15
+ *
16
+ * Publishes an article to the blog platform with full idempotency support.
17
+ * This endpoint receives content from Bloggy core and stores it for rendering.
18
+ *
19
+ * Request body should contain a PublishPayload with:
20
+ * - article: The complete article data
21
+ * - idempotencyKey: SHA-256 hash for duplicate prevention
22
+ * - Optional overrides for slug, meta description, etc.
23
+ */
24
+ export async function POST(
25
+ request: NextRequest,
26
+ ): Promise<NextResponse<PublishResult>> {
27
+ try {
28
+ // Authenticate the request
29
+ const auth = await authenticateRequest(request);
30
+ if (!auth.authenticated) {
31
+ const authCheck = await requireAuth(request);
32
+ return createCorsResponse(request, authCheck.error!, { status: 401 });
33
+ }
34
+
35
+ // Apply rate limiting using the authentication token
36
+ const token = auth.token ?? "anonymous";
37
+ const rateLimitCheck = await enforceRateLimit(token);
38
+ if (!rateLimitCheck.allowed) {
39
+ // Extract rate limit info from error response, with safe fallback
40
+ const rateLimitData =
41
+ rateLimitCheck.errorResponse.error?.details?.rateLimit;
42
+ const rateLimitResult =
43
+ rateLimitData &&
44
+ typeof rateLimitData === "object" &&
45
+ "limit" in rateLimitData &&
46
+ "remaining" in rateLimitData &&
47
+ "reset" in rateLimitData &&
48
+ "count" in rateLimitData &&
49
+ "success" in rateLimitData
50
+ ? (rateLimitData as {
51
+ limit: number;
52
+ remaining: number;
53
+ reset: number;
54
+ count: number;
55
+ success: boolean;
56
+ })
57
+ : {
58
+ limit: 10,
59
+ remaining: 0,
60
+ reset: Date.now() + 60000,
61
+ count: 10,
62
+ success: false,
63
+ };
64
+
65
+ const rateLimitHeaders = getRateLimitHeaders(rateLimitResult);
66
+
67
+ return createCorsResponse(request, rateLimitCheck.errorResponse, {
68
+ status: 429,
69
+ headers: rateLimitHeaders,
70
+ });
71
+ }
72
+
73
+ // Validate request content type
74
+ const contentType = request.headers.get("content-type");
75
+ if (!contentType?.includes("application/json")) {
76
+ return createCorsResponse(
77
+ request,
78
+ {
79
+ success: false,
80
+ error: {
81
+ code: "PUB001",
82
+ message: "Content-Type must be application/json",
83
+ },
84
+ idempotencyKey: "",
85
+ },
86
+ { status: 400 },
87
+ );
88
+ }
89
+
90
+ // Parse request body
91
+ let payload: PublishPayload;
92
+ try {
93
+ payload = (await request.json()) as PublishPayload;
94
+ } catch {
95
+ return createCorsResponse(
96
+ request,
97
+ {
98
+ success: false,
99
+ error: {
100
+ code: "PUB001",
101
+ message: "Invalid JSON in request body",
102
+ },
103
+ idempotencyKey: "",
104
+ },
105
+ { status: 400 },
106
+ );
107
+ }
108
+
109
+ // Validate required fields
110
+ const validationErrors = validatePublishPayload(payload);
111
+ if (validationErrors.length > 0) {
112
+ return createCorsResponse(
113
+ request,
114
+ {
115
+ success: false,
116
+ error: {
117
+ code: "PUB001",
118
+ message: "Missing required fields",
119
+ details: { errors: validationErrors },
120
+ },
121
+ idempotencyKey: payload.idempotencyKey ?? "",
122
+ },
123
+ { status: 400 },
124
+ );
125
+ }
126
+
127
+ // Check version compatibility if header provided
128
+ const clientVersionHeader = request.headers.get("X-Bloggy-Version");
129
+ if (clientVersionHeader) {
130
+ const parsedVersions = parseVersionHeader(clientVersionHeader);
131
+ if (parsedVersions?.platformVersion) {
132
+ const platformVersion = getBlogPlatformVersion();
133
+
134
+ // Check for major version incompatibility
135
+ if (parsedVersions.platformVersion.major !== platformVersion.major) {
136
+ return createCorsResponse(
137
+ request,
138
+ {
139
+ success: false,
140
+ error: {
141
+ code: "PUB009",
142
+ message:
143
+ "Major version incompatibility. Please upgrade your blog platform package.",
144
+ details: {
145
+ clientVersion: parsedVersions.platformVersion.version,
146
+ serverVersion: platformVersion.version,
147
+ },
148
+ },
149
+ idempotencyKey: payload.idempotencyKey,
150
+ },
151
+ { status: 409 },
152
+ );
153
+ }
154
+ }
155
+ }
156
+
157
+ // Handle dry run validation
158
+ if (payload.dryRun) {
159
+ const warnings: string[] = [];
160
+
161
+ // Validate content requirements
162
+ if (payload.article.wordCount < 50) {
163
+ warnings.push("Article has fewer than 50 words, which may affect SEO");
164
+ }
165
+
166
+ if (!payload.article.excerpt || payload.article.excerpt.length < 120) {
167
+ warnings.push(
168
+ "Article excerpt is shorter than recommended 120 characters",
169
+ );
170
+ }
171
+
172
+ if (!payload.article.meta.keywords.length) {
173
+ warnings.push("No SEO keywords provided");
174
+ }
175
+
176
+ return createCorsResponse(
177
+ request,
178
+ {
179
+ success: true,
180
+ warnings,
181
+ idempotencyKey: payload.idempotencyKey,
182
+ },
183
+ { status: 200 },
184
+ );
185
+ }
186
+
187
+ // Prepare the article with any overrides
188
+ const finalArticle: PublishedArticle = {
189
+ ...payload.article,
190
+ slug: payload.slug ?? payload.article.slug,
191
+ meta: {
192
+ ...payload.article.meta,
193
+ description:
194
+ payload.metaDescription ?? payload.article.meta.description,
195
+ },
196
+ };
197
+
198
+ // Validate slug format
199
+ if (!isValidSlug(finalArticle.slug)) {
200
+ return createCorsResponse(
201
+ request,
202
+ {
203
+ success: false,
204
+ error: {
205
+ code: "PUB002",
206
+ message:
207
+ "Slug can only contain lowercase letters, numbers, and hyphens",
208
+ },
209
+ idempotencyKey: payload.idempotencyKey,
210
+ },
211
+ { status: 400 },
212
+ );
213
+ }
214
+
215
+ // Get storage and attempt to save
216
+ const storage = getStorage();
217
+ const result = await storage.saveArticle(
218
+ finalArticle,
219
+ payload.idempotencyKey,
220
+ );
221
+
222
+ // Prepare response headers
223
+ const platformVersion = getBlogPlatformVersion();
224
+ const rateLimitHeaders = getRateLimitHeaders(
225
+ rateLimitCheck.rateLimitResult,
226
+ );
227
+
228
+ const responseHeaders: Record<string, string> = {
229
+ "X-Bloggy-Version": `platform/${platformVersion.version}`,
230
+ "X-Idempotency-Key": payload.idempotencyKey,
231
+ ...rateLimitHeaders,
232
+ };
233
+
234
+ if (result.success) {
235
+ responseHeaders.Location = result.url ?? `/${finalArticle.slug}`;
236
+ }
237
+
238
+ // Return appropriate status code
239
+ const statusCode = result.success
240
+ ? 201
241
+ : result.error?.code === "PUB003"
242
+ ? 409
243
+ : 400;
244
+
245
+ return createCorsResponse(request, result, {
246
+ status: statusCode,
247
+ headers: responseHeaders,
248
+ });
249
+ } catch (error) {
250
+ console.error("Publish endpoint error:", error);
251
+
252
+ const errorResult: PublishResult = {
253
+ success: false,
254
+ error: {
255
+ code: "PUB007",
256
+ message: "Internal server error during publishing",
257
+ },
258
+ idempotencyKey: "",
259
+ };
260
+
261
+ return createCorsResponse(request, errorResult, { status: 500 });
262
+ }
263
+ }
264
+
265
+ /**
266
+ * OPTIONS /api/blog/publish
267
+ *
268
+ * Handle CORS preflight requests
269
+ */
270
+ export async function OPTIONS(request: NextRequest): Promise<NextResponse> {
271
+ return handleCorsOptions(request, ["POST"], {
272
+ allowedHeaders: [
273
+ "Content-Type",
274
+ "X-Bloggy-Version",
275
+ "X-Idempotency-Key",
276
+ "Authorization",
277
+ "X-Requested-With",
278
+ ],
279
+ });
280
+ }
281
+
282
+ /**
283
+ * Validate the publish payload structure
284
+ */
285
+ function validatePublishPayload(payload: unknown): string[] {
286
+ const errors: string[] = [];
287
+
288
+ if (!payload || typeof payload !== "object") {
289
+ errors.push("Request body must be a valid object");
290
+ return errors;
291
+ }
292
+
293
+ const p = payload as Record<string, unknown>;
294
+
295
+ // Check required top-level fields
296
+ if (!p.article || typeof p.article !== "object") {
297
+ errors.push("Missing required field: article");
298
+ }
299
+
300
+ if (!p.idempotencyKey || typeof p.idempotencyKey !== "string") {
301
+ errors.push("Missing required field: idempotencyKey");
302
+ }
303
+
304
+ // Validate article structure if present
305
+ if (p.article && typeof p.article === "object") {
306
+ const article = p.article as Record<string, unknown>;
307
+
308
+ const requiredFields = [
309
+ "id",
310
+ "slug",
311
+ "title",
312
+ "content",
313
+ "publishedAt",
314
+ "site",
315
+ ];
316
+ for (const field of requiredFields) {
317
+ if (!article[field]) {
318
+ errors.push(`Missing required article field: ${field}`);
319
+ }
320
+ }
321
+
322
+ // Validate site structure
323
+ if (article.site && typeof article.site === "object") {
324
+ const site = article.site as Record<string, unknown>;
325
+ const requiredSiteFields = ["id", "name", "domain"];
326
+ for (const field of requiredSiteFields) {
327
+ if (!site[field]) {
328
+ errors.push(`Missing required site field: ${field}`);
329
+ }
330
+ }
331
+ }
332
+
333
+ // Validate meta structure
334
+ if (article.meta && typeof article.meta === "object") {
335
+ const meta = article.meta as Record<string, unknown>;
336
+ if (!Array.isArray(meta.keywords)) {
337
+ errors.push("Article meta.keywords must be an array");
338
+ }
339
+ }
340
+ }
341
+
342
+ return errors;
343
+ }
344
+
345
+ /**
346
+ * Validate slug format
347
+ * Must be lowercase, alphanumeric, and hyphens only
348
+ */
349
+ function isValidSlug(slug: string): boolean {
350
+ if (!slug || typeof slug !== "string") {
351
+ return false;
352
+ }
353
+
354
+ // Allow lowercase letters, numbers, and hyphens
355
+ // Must start and end with alphanumeric character
356
+ const slugRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
357
+ return slugRegex.test(slug) && slug.length >= 1 && slug.length <= 100;
358
+ }
359
+
360
+ export { isValidSlug };
@@ -0,0 +1,166 @@
1
+ import { type NextRequest, NextResponse } from "next/server";
2
+ import { getStorage } from "@/lib/database";
3
+ import { getBlogPlatformVersion, parseVersionHeader } from "@/lib/version";
4
+ import { requireAuth, authenticateRequest } from "@/lib/auth";
5
+ import { enforceRateLimit, getRateLimitHeaders } from "@/lib/ratelimit";
6
+ import { createCorsResponse, handleCorsOptions } from "@/lib/cors";
7
+ import { isValidSlug } from "@/app/api/blog/publish/route"; // Reuse slug validation from publish
8
+
9
+ /**
10
+ * GET /api/blog/slugs/{slug}
11
+ *
12
+ * Checks if a slug is already in use on the blog platform.
13
+ * Used for dry-run validation in publishing workflow.
14
+ *
15
+ * Returns: { exists: boolean, message?: string }
16
+ * - exists: false -> available
17
+ * - exists: true -> taken, with suggestion message
18
+ */
19
+ export async function GET(
20
+ request: NextRequest,
21
+ { params }: { params: Promise<{ slug: string }> },
22
+ ): Promise<NextResponse> {
23
+ try {
24
+ const { slug } = await params;
25
+ // Authenticate (required for security)
26
+ const auth = await authenticateRequest(request);
27
+ if (!auth.authenticated) {
28
+ const authCheck = await requireAuth(request);
29
+ return NextResponse.json(authCheck.error!, { status: 401 });
30
+ }
31
+
32
+ const token = auth.token ?? "anonymous";
33
+
34
+ // Rate limiting (same as publish: 5/min per token)
35
+ const rateLimitCheck = await enforceRateLimit(token);
36
+ if (!rateLimitCheck.allowed) {
37
+ const rateLimitData = (rateLimitCheck.errorResponse.error?.details
38
+ ?.rateLimit as {
39
+ limit: number;
40
+ remaining: number;
41
+ reset: number;
42
+ count: number;
43
+ success: boolean;
44
+ }) || {
45
+ limit: 10,
46
+ remaining: 0,
47
+ reset: Date.now() + 60000,
48
+ count: 10,
49
+ success: false,
50
+ };
51
+
52
+ const rateLimitHeaders = getRateLimitHeaders(rateLimitData);
53
+
54
+ return createCorsResponse(request, rateLimitCheck.errorResponse, {
55
+ status: 429,
56
+ headers: rateLimitHeaders,
57
+ });
58
+ }
59
+
60
+ // Extract slug from params
61
+ if (!slug || !isValidSlug(slug)) {
62
+ return createCorsResponse(
63
+ request,
64
+ {
65
+ exists: false, // Treat invalid as non-existent, but error for clarity
66
+ error: {
67
+ code: "PUB002",
68
+ message:
69
+ "Invalid slug format. Must be lowercase letters, numbers, and hyphens only.",
70
+ },
71
+ },
72
+ { status: 400 },
73
+ );
74
+ }
75
+
76
+ // Version compatibility check
77
+ const clientVersionHeader = request.headers.get("X-Bloggy-Version");
78
+ if (clientVersionHeader) {
79
+ const parsedVersions = parseVersionHeader(clientVersionHeader);
80
+ if (parsedVersions?.platformVersion) {
81
+ const platformVersion = getBlogPlatformVersion();
82
+ if (parsedVersions.platformVersion.major !== platformVersion.major) {
83
+ return createCorsResponse(
84
+ request,
85
+ {
86
+ exists: false,
87
+ error: {
88
+ code: "PUB009",
89
+ message:
90
+ "Major version incompatibility. Please upgrade your blog platform package.",
91
+ details: {
92
+ clientVersion: parsedVersions.platformVersion.version,
93
+ serverVersion: platformVersion.version,
94
+ },
95
+ },
96
+ },
97
+ { status: 409 },
98
+ );
99
+ }
100
+ }
101
+ }
102
+
103
+ // Check existence via storage
104
+ const storage = getStorage();
105
+ const existingArticle = await storage.getArticle(slug);
106
+ const exists = existingArticle !== null;
107
+
108
+ const responseBody = {
109
+ exists,
110
+ ...(exists && {
111
+ message:
112
+ "Slug is already in use. Please choose another (e.g., append '-v2').",
113
+ }),
114
+ };
115
+
116
+ // Headers
117
+ const platformVersion = getBlogPlatformVersion();
118
+ const rateLimitHeaders = getRateLimitHeaders(
119
+ rateLimitCheck.rateLimitResult,
120
+ );
121
+ const responseHeaders: Record<string, string> = {
122
+ "X-Bloggy-Version": `platform/${platformVersion.version}`,
123
+ ...rateLimitHeaders,
124
+ };
125
+
126
+ // Optional idempotency log (if provided)
127
+ const idempotencyKey = request.headers.get("X-Idempotency-Key");
128
+ if (idempotencyKey) {
129
+ responseHeaders["X-Idempotency-Key"] = idempotencyKey;
130
+ // Log for debugging
131
+ console.log(
132
+ `Slug check for idempotency: ${idempotencyKey}, exists: ${exists}`,
133
+ );
134
+ }
135
+
136
+ return createCorsResponse(request, responseBody, {
137
+ status: 200,
138
+ headers: responseHeaders,
139
+ });
140
+ } catch (error: unknown) {
141
+ console.error(
142
+ "Slug existence check error:",
143
+ error instanceof Error ? error.message : String(error),
144
+ );
145
+
146
+ return createCorsResponse(
147
+ request,
148
+ {
149
+ exists: false,
150
+ error: {
151
+ code: "PUB007",
152
+ message: "Internal server error during slug check",
153
+ },
154
+ },
155
+ { status: 500 },
156
+ );
157
+ }
158
+ }
159
+
160
+ /**
161
+ * OPTIONS /api/blog/slugs/{slug}
162
+ * Handle CORS preflight
163
+ */
164
+ export async function OPTIONS(request: NextRequest): Promise<NextResponse> {
165
+ return handleCorsOptions(request, ["GET"]);
166
+ }