@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,301 @@
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 { UnpublishPayload, PublishResult } from "@/types/contracts";
8
+
9
+ /**
10
+ * POST /api/blog/unpublish
11
+ *
12
+ * Unpublishes an article from the blog platform with full idempotency support.
13
+ * This endpoint receives unpublish requests from Bloggy core and removes the content.
14
+ *
15
+ * Request body should contain an UnpublishPayload with:
16
+ * - slug: The article slug to unpublish
17
+ * - idempotencyKey: SHA-256 hash for duplicate prevention
18
+ * - Optional dry run flag for validation
19
+ */
20
+ export async function POST(
21
+ request: NextRequest,
22
+ ): Promise<NextResponse<PublishResult>> {
23
+ try {
24
+ // Authenticate the request
25
+ const auth = await authenticateRequest(request);
26
+ if (!auth.authenticated) {
27
+ const authCheck = await requireAuth(request);
28
+ return NextResponse.json(authCheck.error!, { status: 401 });
29
+ }
30
+
31
+ // Apply rate limiting using the authentication token
32
+ const token = auth.token ?? "anonymous";
33
+ const rateLimitCheck = await enforceRateLimit(token);
34
+ if (!rateLimitCheck.allowed) {
35
+ // Extract rate limit info from error response, with safe fallback
36
+ const rateLimitData =
37
+ rateLimitCheck.errorResponse.error?.details?.rateLimit;
38
+ const rateLimitResult =
39
+ rateLimitData &&
40
+ typeof rateLimitData === "object" &&
41
+ "limit" in rateLimitData &&
42
+ "remaining" in rateLimitData &&
43
+ "reset" in rateLimitData &&
44
+ "count" in rateLimitData &&
45
+ "success" in rateLimitData
46
+ ? (rateLimitData as {
47
+ limit: number;
48
+ remaining: number;
49
+ reset: number;
50
+ count: number;
51
+ success: boolean;
52
+ })
53
+ : {
54
+ limit: 10,
55
+ remaining: 0,
56
+ reset: Date.now() + 60000,
57
+ count: 10,
58
+ success: false,
59
+ };
60
+
61
+ const rateLimitHeaders = getRateLimitHeaders(rateLimitResult);
62
+
63
+ return createCorsResponse(request, rateLimitCheck.errorResponse, {
64
+ status: 429,
65
+ headers: rateLimitHeaders,
66
+ });
67
+ }
68
+
69
+ // Validate request content type
70
+ const contentType = request.headers.get("content-type");
71
+ if (!contentType?.includes("application/json")) {
72
+ return createCorsResponse(
73
+ request,
74
+ {
75
+ success: false,
76
+ error: {
77
+ code: "PUB001",
78
+ message: "Content-Type must be application/json",
79
+ },
80
+ idempotencyKey: "",
81
+ },
82
+ { status: 400 },
83
+ );
84
+ }
85
+
86
+ // Parse request body
87
+ let payload: UnpublishPayload;
88
+ try {
89
+ payload = (await request.json()) as UnpublishPayload;
90
+ } catch {
91
+ return createCorsResponse(
92
+ request,
93
+ {
94
+ success: false,
95
+ error: {
96
+ code: "PUB001",
97
+ message: "Invalid JSON in request body",
98
+ },
99
+ idempotencyKey: "",
100
+ },
101
+ { status: 400 },
102
+ );
103
+ }
104
+
105
+ // Validate required fields
106
+ const validationErrors = validateUnpublishPayload(payload);
107
+ if (validationErrors.length > 0) {
108
+ return createCorsResponse(
109
+ request,
110
+ {
111
+ success: false,
112
+ error: {
113
+ code: "PUB001",
114
+ message: "Missing required fields",
115
+ details: { errors: validationErrors },
116
+ },
117
+ idempotencyKey: payload.idempotencyKey ?? "",
118
+ },
119
+ { status: 400 },
120
+ );
121
+ }
122
+
123
+ // Check version compatibility if header provided
124
+ const clientVersionHeader = request.headers.get("X-Bloggy-Version");
125
+ if (clientVersionHeader) {
126
+ const parsedVersions = parseVersionHeader(clientVersionHeader);
127
+ if (parsedVersions?.platformVersion) {
128
+ const platformVersion = getBlogPlatformVersion();
129
+
130
+ // Check for major version incompatibility
131
+ if (parsedVersions.platformVersion.major !== platformVersion.major) {
132
+ return createCorsResponse(
133
+ request,
134
+ {
135
+ success: false,
136
+ error: {
137
+ code: "PUB009",
138
+ message:
139
+ "Major version incompatibility. Please upgrade your blog platform package.",
140
+ details: {
141
+ clientVersion: parsedVersions.platformVersion.version,
142
+ serverVersion: platformVersion.version,
143
+ },
144
+ },
145
+ idempotencyKey: payload.idempotencyKey,
146
+ },
147
+ { status: 409 },
148
+ );
149
+ }
150
+ }
151
+ }
152
+
153
+ // Validate slug format
154
+ if (!isValidSlug(payload.slug)) {
155
+ return createCorsResponse(
156
+ request,
157
+ {
158
+ success: false,
159
+ error: {
160
+ code: "PUB002",
161
+ message:
162
+ "Slug can only contain lowercase letters, numbers, and hyphens",
163
+ },
164
+ idempotencyKey: payload.idempotencyKey,
165
+ },
166
+ { status: 400 },
167
+ );
168
+ }
169
+
170
+ // Handle dry run validation
171
+ if (payload.dryRun) {
172
+ const storage = getStorage();
173
+ const existingArticle = await storage.getArticle(payload.slug);
174
+
175
+ const warnings: string[] = [];
176
+ if (!existingArticle) {
177
+ warnings.push("Article with this slug does not exist");
178
+ }
179
+
180
+ return createCorsResponse(
181
+ request,
182
+ {
183
+ success: true,
184
+ warnings,
185
+ idempotencyKey: payload.idempotencyKey,
186
+ },
187
+ { status: 200 },
188
+ );
189
+ }
190
+
191
+ // Get storage and attempt to remove article
192
+ const storage = getStorage();
193
+ const result = await storage.removeArticle(
194
+ payload.slug,
195
+ payload.idempotencyKey,
196
+ );
197
+
198
+ // Prepare response headers
199
+ const platformVersion = getBlogPlatformVersion();
200
+ const rateLimitHeaders = getRateLimitHeaders(
201
+ rateLimitCheck.rateLimitResult,
202
+ );
203
+
204
+ const responseHeaders = {
205
+ "X-Bloggy-Version": `platform/${platformVersion.version}`,
206
+ "X-Idempotency-Key": payload.idempotencyKey,
207
+ ...rateLimitHeaders,
208
+ };
209
+
210
+ // Return appropriate status code
211
+ const statusCode = result.success
212
+ ? 200
213
+ : result.error?.code === "PUB007"
214
+ ? 404
215
+ : 400;
216
+
217
+ return createCorsResponse(request, result, {
218
+ status: statusCode,
219
+ headers: responseHeaders,
220
+ });
221
+ } catch (error) {
222
+ console.error("Unpublish endpoint error:", error);
223
+
224
+ const errorResult: PublishResult = {
225
+ success: false,
226
+ error: {
227
+ code: "PUB007",
228
+ message: "Internal server error during unpublishing",
229
+ },
230
+ idempotencyKey: "",
231
+ };
232
+
233
+ return createCorsResponse(request, errorResult, { status: 500 });
234
+ }
235
+ }
236
+
237
+ /**
238
+ * DELETE /api/blog/unpublish
239
+ *
240
+ * Alternative HTTP method for unpublishing (some clients prefer DELETE for removal operations)
241
+ * Accepts the same payload structure as POST
242
+ */
243
+ export async function DELETE(
244
+ request: NextRequest,
245
+ ): Promise<NextResponse<PublishResult>> {
246
+ return POST(request);
247
+ }
248
+
249
+ /**
250
+ * OPTIONS /api/blog/unpublish
251
+ *
252
+ * Handle CORS preflight requests
253
+ */
254
+ export async function OPTIONS(request: NextRequest): Promise<NextResponse> {
255
+ return handleCorsOptions(request, ["POST", "DELETE"]);
256
+ }
257
+
258
+ /**
259
+ * Validate the unpublish payload structure
260
+ */
261
+ function validateUnpublishPayload(payload: unknown): string[] {
262
+ const errors: string[] = [];
263
+
264
+ if (!payload || typeof payload !== "object") {
265
+ errors.push("Request body must be a valid object");
266
+ return errors;
267
+ }
268
+
269
+ const p = payload as Record<string, unknown>;
270
+
271
+ // Check required fields
272
+ if (!p.slug || typeof p.slug !== "string") {
273
+ errors.push("Missing required field: slug");
274
+ }
275
+
276
+ if (!p.idempotencyKey || typeof p.idempotencyKey !== "string") {
277
+ errors.push("Missing required field: idempotencyKey");
278
+ }
279
+
280
+ // Validate slug is not empty
281
+ if (p.slug && typeof p.slug === "string" && !p.slug.trim()) {
282
+ errors.push("Slug cannot be empty");
283
+ }
284
+
285
+ return errors;
286
+ }
287
+
288
+ /**
289
+ * Validate slug format
290
+ * Must be lowercase, alphanumeric, and hyphens only
291
+ */
292
+ function isValidSlug(slug: string): boolean {
293
+ if (!slug || typeof slug !== "string") {
294
+ return false;
295
+ }
296
+
297
+ // Allow lowercase letters, numbers, and hyphens
298
+ // Must start and end with alphanumeric character
299
+ const slugRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
300
+ return slugRegex.test(slug) && slug.length >= 1 && slug.length <= 100;
301
+ }
@@ -0,0 +1,259 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { NextResponse } from "next/server";
3
+ import {
4
+ getBlogPlatformVersion,
5
+ parseVersionHeader,
6
+ checkVersionCompatibility,
7
+ type VersionCompatibilityResult,
8
+ type VersionInfo,
9
+ } from "@/lib/version";
10
+ import { requireAuth, authenticateRequest } from "@/lib/auth";
11
+ import { enforceRateLimit, getRateLimitHeaders } from "@/lib/ratelimit";
12
+ import { createCorsResponse, handleCorsOptions } from "@/lib/cors";
13
+
14
+ /**
15
+ * Version compatibility response
16
+ */
17
+ interface VersionResponse {
18
+ success: boolean;
19
+ versions: {
20
+ bloggy: VersionInfo;
21
+ platform: VersionInfo;
22
+ };
23
+ compatibility: VersionCompatibilityResult;
24
+ error?: string;
25
+ }
26
+
27
+ /**
28
+ * GET /api/blog/version
29
+ *
30
+ * Returns version information and compatibility status for Bloggy core and blog platform.
31
+ * Used by customers to verify compatibility before publishing.
32
+ *
33
+ * Optional X-Bloggy-Version header can be provided to check compatibility
34
+ * with specific versions.
35
+ */
36
+ export async function GET(
37
+ request: NextRequest,
38
+ ): Promise<NextResponse<VersionResponse>> {
39
+ try {
40
+ // Authenticate the request
41
+ const auth = await authenticateRequest(request);
42
+ if (!auth.authenticated) {
43
+ const authCheck = await requireAuth(request);
44
+ const errorResponse: VersionResponse = {
45
+ success: false,
46
+ versions: {
47
+ bloggy: {
48
+ name: "unknown",
49
+ version: "unknown",
50
+ major: 0,
51
+ minor: 0,
52
+ patch: 0,
53
+ },
54
+ platform: {
55
+ name: "unknown",
56
+ version: "unknown",
57
+ major: 0,
58
+ minor: 0,
59
+ patch: 0,
60
+ },
61
+ },
62
+ compatibility: {
63
+ compatible: false,
64
+ errorMessage: authCheck.error?.error?.message ?? "Authentication required",
65
+ },
66
+ };
67
+ return createCorsResponse(request, errorResponse, { status: 401 });
68
+ }
69
+
70
+ // Apply rate limiting using the authentication token
71
+ const token = auth.token ?? "anonymous";
72
+ const rateLimitCheck = await enforceRateLimit(token);
73
+ if (!rateLimitCheck.allowed) {
74
+ // Extract rate limit info from error response, with safe fallback
75
+ const rateLimitData =
76
+ rateLimitCheck.errorResponse.error?.details?.rateLimit;
77
+ const rateLimitResult =
78
+ rateLimitData &&
79
+ typeof rateLimitData === "object" &&
80
+ "limit" in rateLimitData &&
81
+ "remaining" in rateLimitData &&
82
+ "reset" in rateLimitData &&
83
+ "count" in rateLimitData &&
84
+ "success" in rateLimitData
85
+ ? (rateLimitData as {
86
+ limit: number;
87
+ remaining: number;
88
+ reset: number;
89
+ count: number;
90
+ success: boolean;
91
+ })
92
+ : {
93
+ limit: 10,
94
+ remaining: 0,
95
+ reset: Date.now() + 60000,
96
+ count: 10,
97
+ success: false,
98
+ };
99
+
100
+ const rateLimitHeaders = getRateLimitHeaders(rateLimitResult);
101
+
102
+ // Create a proper version response with error
103
+ const errorVersionResponse: VersionResponse = {
104
+ success: false,
105
+ versions: {
106
+ bloggy: {
107
+ name: "unknown",
108
+ version: "unknown",
109
+ major: 0,
110
+ minor: 0,
111
+ patch: 0,
112
+ },
113
+ platform: {
114
+ name: "unknown",
115
+ version: "unknown",
116
+ major: 0,
117
+ minor: 0,
118
+ patch: 0,
119
+ },
120
+ },
121
+ compatibility: {
122
+ compatible: false,
123
+ errorMessage:
124
+ rateLimitCheck.errorResponse.error?.message ??
125
+ "Rate limit exceeded",
126
+ },
127
+ };
128
+
129
+ return createCorsResponse(request, errorVersionResponse, {
130
+ status: 429,
131
+ headers: rateLimitHeaders,
132
+ });
133
+ }
134
+
135
+ // Get current platform version information
136
+ const platformVersion = getBlogPlatformVersion();
137
+
138
+ // Parse Bloggy version from X-Bloggy-Version header
139
+ const clientVersionHeader = request.headers.get("X-Bloggy-Version");
140
+ let bloggyVersion: VersionInfo = {
141
+ name: "unknown",
142
+ version: "unknown",
143
+ major: 0,
144
+ minor: 0,
145
+ patch: 0,
146
+ };
147
+
148
+ if (clientVersionHeader) {
149
+ try {
150
+ // Try parsing as simple version string first (e.g., "0.1.0")
151
+ if (/^\d+\.\d+\.\d+/.test(clientVersionHeader)) {
152
+ const parts = clientVersionHeader.split(".");
153
+ const major = parseInt(parts[0] ?? "0", 10);
154
+ const minor = parseInt(parts[1] ?? "0", 10);
155
+ const patch = parseInt(parts[2]?.split("-")[0] ?? "0", 10);
156
+
157
+ bloggyVersion = {
158
+ name: "bloggy",
159
+ version: clientVersionHeader,
160
+ major,
161
+ minor,
162
+ patch,
163
+ };
164
+ } else {
165
+ // Try parsing as formatted header (e.g., "bloggy/1.0.0 platform/1.0.0")
166
+ const parsedVersions = parseVersionHeader(clientVersionHeader);
167
+ if (parsedVersions?.bloggyVersion) {
168
+ bloggyVersion = parsedVersions.bloggyVersion;
169
+ }
170
+ }
171
+ } catch (error) {
172
+ console.warn("Failed to parse X-Bloggy-Version header:", error);
173
+ }
174
+ }
175
+
176
+ // Check compatibility between Bloggy core and platform versions
177
+ // Only perform check if we have a valid Bloggy version (not "unknown")
178
+ let compatibility: VersionCompatibilityResult = { compatible: true };
179
+
180
+ if (bloggyVersion.version !== "unknown") {
181
+ // Compare Bloggy core version with platform version
182
+ // Both should follow semantic versioning
183
+ compatibility = checkVersionCompatibility(bloggyVersion, platformVersion);
184
+ }
185
+
186
+ const response: VersionResponse = {
187
+ success: true,
188
+ versions: {
189
+ bloggy: bloggyVersion,
190
+ platform: platformVersion,
191
+ },
192
+ compatibility,
193
+ };
194
+
195
+ // Prepare response headers
196
+ const rateLimitHeaders = getRateLimitHeaders(
197
+ rateLimitCheck.rateLimitResult,
198
+ );
199
+
200
+ const responseHeaders = {
201
+ "X-Bloggy-Version": `platform/${platformVersion.version}`,
202
+ "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
203
+ ...rateLimitHeaders,
204
+ };
205
+
206
+ // Always return 200 OK with compatibility details in response body
207
+ // The client should check compatibility.compatible field, not HTTP status
208
+ return createCorsResponse(request, response, {
209
+ status: 200,
210
+ headers: responseHeaders,
211
+ });
212
+ } catch (error) {
213
+ console.error("Version endpoint error:", error);
214
+
215
+ const errorResponse: VersionResponse = {
216
+ success: false,
217
+ versions: {
218
+ bloggy: {
219
+ name: "unknown",
220
+ version: "unknown",
221
+ major: 0,
222
+ minor: 0,
223
+ patch: 0,
224
+ },
225
+ platform: {
226
+ name: "unknown",
227
+ version: "unknown",
228
+ major: 0,
229
+ minor: 0,
230
+ patch: 0,
231
+ },
232
+ },
233
+ compatibility: {
234
+ compatible: false,
235
+ errorMessage:
236
+ "Internal server error while checking version compatibility",
237
+ },
238
+ error: error instanceof Error ? error.message : "Unknown error",
239
+ };
240
+
241
+ return createCorsResponse(request, errorResponse, { status: 500 });
242
+ }
243
+ }
244
+
245
+ /**
246
+ * OPTIONS /api/blog/version
247
+ *
248
+ * Handle CORS preflight requests
249
+ */
250
+ export async function OPTIONS(request: NextRequest): Promise<NextResponse> {
251
+ return handleCorsOptions(request, ["GET"], {
252
+ allowedHeaders: [
253
+ "Content-Type",
254
+ "X-Bloggy-Version",
255
+ "Authorization",
256
+ "X-Requested-With",
257
+ ],
258
+ });
259
+ }