@xexr/blog-platform 1.0.7 → 1.0.9
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/CHANGELOG.md +7 -0
- package/README.md +110 -29
- package/dist/app/blog/[slug]/page.d.ts +68 -0
- package/dist/app/blog/[slug]/page.tsx +175 -116
- package/dist/app/blog/authors/[slug]/page.test.ts +108 -0
- package/dist/app/blog/authors/[slug]/page.tsx +387 -0
- package/dist/app/blog/page.tsx +138 -113
- package/dist/components/ArticleReadingProgress.tsx +208 -0
- package/dist/components/ArticleTopActions.tsx +179 -0
- package/dist/components/AuthorByline.tsx +569 -0
- package/dist/components/AuthorPages.test.ts +87 -0
- package/dist/components/AuthorPages.tsx +129 -0
- package/dist/components/ExpandableArticleImage.tsx +79 -0
- package/dist/components/Pagination.tsx +236 -0
- package/dist/components/TopicArchivePages.tsx +204 -0
- package/dist/components/ui/badge.tsx +36 -0
- package/dist/components/ui/button.tsx +59 -0
- package/dist/components/ui/card.tsx +92 -0
- package/dist/components/ui/pagination.tsx +127 -0
- package/dist/components/ui/separator.tsx +29 -0
- package/dist/index.d.mts +174 -112
- package/dist/index.d.ts +174 -112
- package/dist/index.js +83 -8
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +83 -8
- package/dist/index.mjs.map +1 -1
- package/dist/lib/article-ui.ts +158 -0
- package/dist/lib/auth.ts +354 -0
- package/dist/lib/authors.ts +100 -0
- package/dist/lib/cors.ts +243 -0
- package/dist/lib/database.ts +277 -0
- package/dist/lib/init.ts +186 -0
- package/dist/lib/mdx.ts +73 -0
- package/dist/lib/metadata.ts +558 -0
- package/dist/lib/ratelimit.ts +224 -0
- package/dist/lib/security-headers.ts +322 -0
- package/dist/lib/seo.ts +295 -0
- package/dist/lib/sitemap.ts +738 -0
- package/dist/lib/storage/drizzle.ts +400 -0
- package/dist/lib/structured-data.ts +609 -0
- package/dist/lib/topics.ts +56 -0
- package/dist/lib/urls.ts +36 -0
- package/dist/lib/utils.ts +6 -0
- package/dist/lib/version.ts +154 -0
- package/dist/types/contracts.ts +259 -0
- package/dist/types/index.d.mts +20 -1
- package/dist/types/index.d.ts +20 -1
- package/dist/types/index.ts +16 -0
- package/package.json +14 -9
package/dist/lib/cors.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CORS configuration for blog platform API endpoints
|
|
3
|
+
*
|
|
4
|
+
* This provides centralized CORS handling with security best practices
|
|
5
|
+
* for cross-origin requests from Bloggy core applications.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { NextRequest } from "next/server";
|
|
9
|
+
import { NextResponse } from "next/server";
|
|
10
|
+
import { getAPISecurityHeaders, type SecurityConfig } from "./security-headers";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* CORS configuration interface
|
|
14
|
+
*/
|
|
15
|
+
export interface CorsConfig {
|
|
16
|
+
/** Allowed origins (default: allow configured domains only) */
|
|
17
|
+
allowedOrigins?: string[];
|
|
18
|
+
|
|
19
|
+
/** Allow all origins with wildcard (default: false for security) */
|
|
20
|
+
allowWildcard?: boolean;
|
|
21
|
+
|
|
22
|
+
/** Allow credentials to be sent (default: false) */
|
|
23
|
+
allowCredentials?: boolean;
|
|
24
|
+
|
|
25
|
+
/** Cache preflight requests for this many seconds (default: 86400 = 24 hours) */
|
|
26
|
+
maxAge?: number;
|
|
27
|
+
|
|
28
|
+
/** Security headers configuration */
|
|
29
|
+
securityConfig?: SecurityConfig;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Default CORS configuration for blog platform
|
|
34
|
+
*/
|
|
35
|
+
const DEFAULT_CORS_CONFIG: Required<CorsConfig> = {
|
|
36
|
+
allowedOrigins: [
|
|
37
|
+
"http://localhost:3000", // Development Bloggy core
|
|
38
|
+
"https://localhost:3000", // Development Bloggy core (HTTPS)
|
|
39
|
+
"http://127.0.0.1:3000", // Alternative localhost
|
|
40
|
+
"https://127.0.0.1:3000", // Alternative localhost (HTTPS)
|
|
41
|
+
],
|
|
42
|
+
allowWildcard: false, // Security: don't allow all origins by default
|
|
43
|
+
allowCredentials: false, // Security: don't allow credentials by default
|
|
44
|
+
maxAge: 86400, // Cache preflight for 24 hours
|
|
45
|
+
securityConfig: {}, // Default security configuration
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Get CORS configuration from environment or defaults
|
|
50
|
+
*/
|
|
51
|
+
function getCorsConfig(): Required<CorsConfig> {
|
|
52
|
+
const envOrigins = process.env.BLOG_PLATFORM_ALLOWED_ORIGINS;
|
|
53
|
+
const allowWildcard = process.env.BLOG_PLATFORM_ALLOW_WILDCARD === "true";
|
|
54
|
+
const allowCredentials = process.env.BLOG_PLATFORM_ALLOW_CREDENTIALS === "true";
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
...DEFAULT_CORS_CONFIG,
|
|
58
|
+
allowedOrigins: envOrigins ? envOrigins.split(",").map(origin => origin.trim()) : DEFAULT_CORS_CONFIG.allowedOrigins,
|
|
59
|
+
allowWildcard,
|
|
60
|
+
allowCredentials,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Check if an origin is allowed based on CORS configuration
|
|
66
|
+
*/
|
|
67
|
+
function isOriginAllowed(origin: string | null, config: Required<CorsConfig>): boolean {
|
|
68
|
+
if (!origin) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// If wildcard is enabled, allow all origins
|
|
73
|
+
if (config.allowWildcard) {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Check against allowed origins list
|
|
78
|
+
return config.allowedOrigins.includes(origin);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Get CORS headers for a given request and configuration
|
|
83
|
+
*/
|
|
84
|
+
function getCorsHeaders(request: NextRequest, config: Required<CorsConfig>): Record<string, string> {
|
|
85
|
+
const origin = request.headers.get("origin");
|
|
86
|
+
const headers: Record<string, string> = {};
|
|
87
|
+
|
|
88
|
+
// Set allowed origin
|
|
89
|
+
if (config.allowWildcard) {
|
|
90
|
+
headers["Access-Control-Allow-Origin"] = "*";
|
|
91
|
+
} else if (origin && isOriginAllowed(origin, config)) {
|
|
92
|
+
headers["Access-Control-Allow-Origin"] = origin;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Set credentials if allowed (only when not using wildcard)
|
|
96
|
+
if (config.allowCredentials && !config.allowWildcard) {
|
|
97
|
+
headers["Access-Control-Allow-Credentials"] = "true";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Always include Vary header when not using wildcard
|
|
101
|
+
if (!config.allowWildcard) {
|
|
102
|
+
headers.Vary = "Origin";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return headers;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Get preflight CORS headers for OPTIONS requests
|
|
110
|
+
*/
|
|
111
|
+
function getPreflightHeaders(
|
|
112
|
+
request: NextRequest,
|
|
113
|
+
config: Required<CorsConfig>,
|
|
114
|
+
allowedMethods: string[],
|
|
115
|
+
allowedHeaders: string[]
|
|
116
|
+
): Record<string, string> {
|
|
117
|
+
const corsHeaders = getCorsHeaders(request, config);
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
...corsHeaders,
|
|
121
|
+
"Access-Control-Allow-Methods": allowedMethods.join(", "),
|
|
122
|
+
"Access-Control-Allow-Headers": allowedHeaders.join(", "),
|
|
123
|
+
"Access-Control-Max-Age": config.maxAge.toString(),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Standard allowed headers for blog platform API endpoints
|
|
129
|
+
*/
|
|
130
|
+
export const STANDARD_ALLOWED_HEADERS = [
|
|
131
|
+
"Content-Type",
|
|
132
|
+
"X-Bloggy-Version",
|
|
133
|
+
"X-Idempotency-Key",
|
|
134
|
+
"Authorization",
|
|
135
|
+
"X-Requested-With",
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Create CORS-enabled response for API endpoints
|
|
140
|
+
*/
|
|
141
|
+
export function createCorsResponse<T>(
|
|
142
|
+
request: NextRequest,
|
|
143
|
+
data: T,
|
|
144
|
+
options: {
|
|
145
|
+
status?: number;
|
|
146
|
+
headers?: Record<string, string>;
|
|
147
|
+
corsConfig?: CorsConfig;
|
|
148
|
+
} = {}
|
|
149
|
+
): NextResponse<T> {
|
|
150
|
+
const { status = 200, headers = {}, corsConfig = {} } = options;
|
|
151
|
+
const config = { ...getCorsConfig(), ...corsConfig };
|
|
152
|
+
|
|
153
|
+
const corsHeaders = getCorsHeaders(request, config);
|
|
154
|
+
const securityHeaders = getAPISecurityHeaders(request, config.securityConfig);
|
|
155
|
+
|
|
156
|
+
const responseHeaders = {
|
|
157
|
+
"Content-Type": "application/json",
|
|
158
|
+
...corsHeaders,
|
|
159
|
+
...securityHeaders,
|
|
160
|
+
...headers,
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
return NextResponse.json(data, {
|
|
164
|
+
status,
|
|
165
|
+
headers: responseHeaders,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Handle CORS preflight OPTIONS requests
|
|
171
|
+
*/
|
|
172
|
+
export function handleCorsOptions(
|
|
173
|
+
request: NextRequest,
|
|
174
|
+
allowedMethods: string[],
|
|
175
|
+
options: {
|
|
176
|
+
allowedHeaders?: string[];
|
|
177
|
+
corsConfig?: CorsConfig;
|
|
178
|
+
} = {}
|
|
179
|
+
): NextResponse {
|
|
180
|
+
const { allowedHeaders = STANDARD_ALLOWED_HEADERS, corsConfig = {} } = options;
|
|
181
|
+
const config = { ...getCorsConfig(), ...corsConfig };
|
|
182
|
+
|
|
183
|
+
const origin = request.headers.get("origin");
|
|
184
|
+
|
|
185
|
+
// Check if origin is allowed
|
|
186
|
+
if (origin && !config.allowWildcard && !isOriginAllowed(origin, config)) {
|
|
187
|
+
const securityHeaders = getAPISecurityHeaders(request, config.securityConfig);
|
|
188
|
+
return new NextResponse(null, {
|
|
189
|
+
status: 403,
|
|
190
|
+
headers: {
|
|
191
|
+
"Content-Type": "text/plain",
|
|
192
|
+
...securityHeaders,
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const preflightHeaders = getPreflightHeaders(
|
|
198
|
+
request,
|
|
199
|
+
config,
|
|
200
|
+
[...allowedMethods, "OPTIONS"],
|
|
201
|
+
allowedHeaders
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const securityHeaders = getAPISecurityHeaders(request, config.securityConfig);
|
|
205
|
+
|
|
206
|
+
return new NextResponse(null, {
|
|
207
|
+
status: 200,
|
|
208
|
+
headers: {
|
|
209
|
+
...preflightHeaders,
|
|
210
|
+
...securityHeaders,
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Middleware to add CORS headers to any response
|
|
217
|
+
*/
|
|
218
|
+
export function addCorsHeaders(
|
|
219
|
+
request: NextRequest,
|
|
220
|
+
response: NextResponse,
|
|
221
|
+
corsConfig: CorsConfig = {}
|
|
222
|
+
): NextResponse {
|
|
223
|
+
const config = { ...getCorsConfig(), ...corsConfig };
|
|
224
|
+
const corsHeaders = getCorsHeaders(request, config);
|
|
225
|
+
const securityHeaders = getAPISecurityHeaders(request, config.securityConfig);
|
|
226
|
+
|
|
227
|
+
// Add CORS and security headers to existing response
|
|
228
|
+
Object.entries({ ...corsHeaders, ...securityHeaders }).forEach(([key, value]) => {
|
|
229
|
+
response.headers.set(key, value);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
return response;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Check if request origin is allowed (utility for manual CORS checking)
|
|
237
|
+
*/
|
|
238
|
+
export function checkOriginAllowed(request: NextRequest, corsConfig: CorsConfig = {}): boolean {
|
|
239
|
+
const config = { ...getCorsConfig(), ...corsConfig };
|
|
240
|
+
const origin = request.headers.get("origin");
|
|
241
|
+
|
|
242
|
+
return isOriginAllowed(origin, config);
|
|
243
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database interface for blog platform storage
|
|
3
|
+
*
|
|
4
|
+
* This provides a simple interface for storing published articles
|
|
5
|
+
* without coupling to any specific database implementation.
|
|
6
|
+
* Customers can implement this interface with their preferred database.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PublishedArticle, PublishResult } from "@/types/contracts";
|
|
10
|
+
|
|
11
|
+
export interface BlogPlatformStorage {
|
|
12
|
+
/**
|
|
13
|
+
* Store a published article
|
|
14
|
+
*/
|
|
15
|
+
saveArticle(
|
|
16
|
+
article: PublishedArticle,
|
|
17
|
+
idempotencyKey: string,
|
|
18
|
+
): Promise<PublishResult>;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Get an article by slug
|
|
22
|
+
*/
|
|
23
|
+
getArticle(slug: string): Promise<PublishedArticle | null>;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* List all published articles
|
|
27
|
+
*/
|
|
28
|
+
listArticles(options?: {
|
|
29
|
+
limit?: number;
|
|
30
|
+
offset?: number;
|
|
31
|
+
sortBy?: "publishedAt" | "updatedAt";
|
|
32
|
+
order?: "asc" | "desc";
|
|
33
|
+
}): Promise<{
|
|
34
|
+
articles: PublishedArticle[];
|
|
35
|
+
total: number;
|
|
36
|
+
}>;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Remove an article by slug
|
|
40
|
+
*/
|
|
41
|
+
removeArticle(slug: string, idempotencyKey: string): Promise<PublishResult>;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Check if an operation was already performed (idempotency)
|
|
45
|
+
*/
|
|
46
|
+
checkIdempotency(idempotencyKey: string): Promise<PublishResult | null>;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Store the result of an operation for idempotency
|
|
50
|
+
*/
|
|
51
|
+
storeOperationResult(
|
|
52
|
+
idempotencyKey: string,
|
|
53
|
+
result: PublishResult,
|
|
54
|
+
): Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Default in-memory storage implementation for development/testing
|
|
59
|
+
* IMPORTANT: This is not suitable for production use!
|
|
60
|
+
*/
|
|
61
|
+
export class InMemoryStorage implements BlogPlatformStorage {
|
|
62
|
+
private articles = new Map<string, PublishedArticle>();
|
|
63
|
+
private operations = new Map<string, PublishResult>();
|
|
64
|
+
|
|
65
|
+
async saveArticle(
|
|
66
|
+
article: PublishedArticle,
|
|
67
|
+
idempotencyKey: string,
|
|
68
|
+
): Promise<PublishResult> {
|
|
69
|
+
// Check for existing idempotency key
|
|
70
|
+
const existingResult = await this.checkIdempotency(idempotencyKey);
|
|
71
|
+
|
|
72
|
+
// CRITICAL FIX: Allow republish after unpublish
|
|
73
|
+
// If we found a cached operation result AND it was successful,
|
|
74
|
+
// check if the article still exists. If it was removed (unpublished),
|
|
75
|
+
// proceed with the save to allow republishing.
|
|
76
|
+
if (existingResult) {
|
|
77
|
+
if (existingResult.success) {
|
|
78
|
+
// Check if article still exists by finding it in the articles map
|
|
79
|
+
const articleStillExists = Array.from(this.articles.values()).some(
|
|
80
|
+
(a) => a.id === article.id,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// If article was removed, allow republish by not returning cached result
|
|
84
|
+
if (!articleStillExists) {
|
|
85
|
+
// Article was unpublished - proceed with save
|
|
86
|
+
} else {
|
|
87
|
+
// Article still exists - this is a true duplicate
|
|
88
|
+
return existingResult;
|
|
89
|
+
}
|
|
90
|
+
} else {
|
|
91
|
+
// Failed operations are always returned as-is
|
|
92
|
+
return existingResult;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Validate slug uniqueness
|
|
97
|
+
if (this.articles.has(article.slug)) {
|
|
98
|
+
const result: PublishResult = {
|
|
99
|
+
success: false,
|
|
100
|
+
error: {
|
|
101
|
+
code: "PUB003",
|
|
102
|
+
message: "This URL is already in use. Please choose another.",
|
|
103
|
+
},
|
|
104
|
+
idempotencyKey,
|
|
105
|
+
};
|
|
106
|
+
await this.storeOperationResult(idempotencyKey, result);
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Store the article
|
|
111
|
+
this.articles.set(article.slug, article);
|
|
112
|
+
|
|
113
|
+
const result: PublishResult = {
|
|
114
|
+
success: true,
|
|
115
|
+
url: `/${article.slug}`,
|
|
116
|
+
platformPostId: article.id,
|
|
117
|
+
publishedAt: article.publishedAt,
|
|
118
|
+
idempotencyKey,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
await this.storeOperationResult(idempotencyKey, result);
|
|
122
|
+
return result;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async getArticle(slug: string): Promise<PublishedArticle | null> {
|
|
126
|
+
return this.articles.get(slug) ?? null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async listArticles(
|
|
130
|
+
options: {
|
|
131
|
+
limit?: number;
|
|
132
|
+
offset?: number;
|
|
133
|
+
sortBy?: "publishedAt" | "updatedAt";
|
|
134
|
+
order?: "asc" | "desc";
|
|
135
|
+
} = {},
|
|
136
|
+
): Promise<{ articles: PublishedArticle[]; total: number }> {
|
|
137
|
+
const {
|
|
138
|
+
limit = 10,
|
|
139
|
+
offset = 0,
|
|
140
|
+
sortBy = "publishedAt",
|
|
141
|
+
order = "desc",
|
|
142
|
+
} = options;
|
|
143
|
+
|
|
144
|
+
const allArticles = Array.from(this.articles.values());
|
|
145
|
+
|
|
146
|
+
// Sort articles
|
|
147
|
+
allArticles.sort((a, b) => {
|
|
148
|
+
const aValue = new Date(a[sortBy]).getTime();
|
|
149
|
+
const bValue = new Date(b[sortBy]).getTime();
|
|
150
|
+
return order === "desc" ? bValue - aValue : aValue - bValue;
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Apply pagination
|
|
154
|
+
const articles = allArticles.slice(offset, offset + limit);
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
articles,
|
|
158
|
+
total: allArticles.length,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async removeArticle(
|
|
163
|
+
slug: string,
|
|
164
|
+
idempotencyKey: string,
|
|
165
|
+
): Promise<PublishResult> {
|
|
166
|
+
// Check for existing idempotency key
|
|
167
|
+
const existingResult = await this.checkIdempotency(idempotencyKey);
|
|
168
|
+
|
|
169
|
+
// CRITICAL FIX: Allow re-unpublish after republish
|
|
170
|
+
// If we found a cached operation result AND it was successful,
|
|
171
|
+
// check if the article NOW exists. If it was republished,
|
|
172
|
+
// proceed with the removal to allow re-unpublishing.
|
|
173
|
+
if (existingResult) {
|
|
174
|
+
if (existingResult.success) {
|
|
175
|
+
// Check if article exists NOW (might have been republished)
|
|
176
|
+
const articleExists = this.articles.has(slug);
|
|
177
|
+
|
|
178
|
+
// If article EXISTS, it was republished - allow re-unpublish
|
|
179
|
+
if (articleExists) {
|
|
180
|
+
// Article was republished - proceed with removal
|
|
181
|
+
} else {
|
|
182
|
+
// Article still removed - this is a true duplicate
|
|
183
|
+
return existingResult;
|
|
184
|
+
}
|
|
185
|
+
} else {
|
|
186
|
+
// Failed operations are always returned as-is
|
|
187
|
+
return existingResult;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Check if article exists
|
|
192
|
+
if (!this.articles.has(slug)) {
|
|
193
|
+
const result: PublishResult = {
|
|
194
|
+
success: false,
|
|
195
|
+
error: {
|
|
196
|
+
code: "PUB007",
|
|
197
|
+
message: "Article not found",
|
|
198
|
+
},
|
|
199
|
+
idempotencyKey,
|
|
200
|
+
};
|
|
201
|
+
await this.storeOperationResult(idempotencyKey, result);
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Remove the article
|
|
206
|
+
this.articles.delete(slug);
|
|
207
|
+
|
|
208
|
+
const result: PublishResult = {
|
|
209
|
+
success: true,
|
|
210
|
+
idempotencyKey,
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
await this.storeOperationResult(idempotencyKey, result);
|
|
214
|
+
return result;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async checkIdempotency(
|
|
218
|
+
idempotencyKey: string,
|
|
219
|
+
): Promise<PublishResult | null> {
|
|
220
|
+
return this.operations.get(idempotencyKey) ?? null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async storeOperationResult(
|
|
224
|
+
idempotencyKey: string,
|
|
225
|
+
result: PublishResult,
|
|
226
|
+
): Promise<void> {
|
|
227
|
+
this.operations.set(idempotencyKey, result);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
type GlobalWithStorage = typeof globalThis & {
|
|
232
|
+
__BLOG_PLATFORM_STORAGE__?: BlogPlatformStorage;
|
|
233
|
+
__BLOG_PLATFORM_STORAGE_WARNED__?: boolean;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
function warnAboutEphemeralStorage(): void {
|
|
237
|
+
const globalRef = globalThis as GlobalWithStorage;
|
|
238
|
+
if (globalRef.__BLOG_PLATFORM_STORAGE_WARNED__) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
globalRef.__BLOG_PLATFORM_STORAGE_WARNED__ = true;
|
|
243
|
+
|
|
244
|
+
const message =
|
|
245
|
+
"Blog platform: No storage provider configured. Falling back to in-memory storage (data is not persisted).";
|
|
246
|
+
if (process.env.NODE_ENV === "production") {
|
|
247
|
+
console.error(
|
|
248
|
+
`${message} Call initBlogPlatform({ storage: /* your persistent adapter */ }) to avoid data loss.`,
|
|
249
|
+
);
|
|
250
|
+
} else {
|
|
251
|
+
console.warn(message);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function getGlobalStorage(): BlogPlatformStorage {
|
|
256
|
+
const globalRef = globalThis as GlobalWithStorage;
|
|
257
|
+
if (!globalRef.__BLOG_PLATFORM_STORAGE__) {
|
|
258
|
+
warnAboutEphemeralStorage();
|
|
259
|
+
globalRef.__BLOG_PLATFORM_STORAGE__ = new InMemoryStorage();
|
|
260
|
+
}
|
|
261
|
+
const storage = globalRef.__BLOG_PLATFORM_STORAGE__;
|
|
262
|
+
if (!storage) {
|
|
263
|
+
// This should be unreachable because we set it above,
|
|
264
|
+
// but the guard satisfies strict type checking without assertions.
|
|
265
|
+
throw new Error("Blog platform storage not initialized");
|
|
266
|
+
}
|
|
267
|
+
return storage;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function getStorage(): BlogPlatformStorage {
|
|
271
|
+
return getGlobalStorage();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function setStorage(storage: BlogPlatformStorage): void {
|
|
275
|
+
const globalRef = globalThis as GlobalWithStorage;
|
|
276
|
+
globalRef.__BLOG_PLATFORM_STORAGE__ = storage;
|
|
277
|
+
}
|
package/dist/lib/init.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Initialization utilities for the blog platform package
|
|
3
|
+
*
|
|
4
|
+
* This module provides convenient setup functions for customers
|
|
5
|
+
* to configure authentication, storage, and other platform features.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
setAuthConfig,
|
|
10
|
+
type AuthConfig,
|
|
11
|
+
NoAuth,
|
|
12
|
+
EnvAuth,
|
|
13
|
+
} from "./auth";
|
|
14
|
+
import { setStorage, type BlogPlatformStorage } from "./database";
|
|
15
|
+
import {
|
|
16
|
+
autoConfigureRateLimit,
|
|
17
|
+
configureRateLimit,
|
|
18
|
+
type RateLimitConfig,
|
|
19
|
+
} from "./ratelimit";
|
|
20
|
+
|
|
21
|
+
declare global {
|
|
22
|
+
// Using `var` here intentionally to declare a global on Node's globalThis
|
|
23
|
+
// across module boundaries. This is not a directive; remove stale disable.
|
|
24
|
+
var __BLOG_PLATFORM_INITIALISED__: boolean | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Configuration options for blog platform initialization
|
|
29
|
+
*/
|
|
30
|
+
export interface BlogPlatformConfig {
|
|
31
|
+
/** Authentication configuration (REQUIRED) */
|
|
32
|
+
auth: AuthConfig;
|
|
33
|
+
|
|
34
|
+
/** Custom storage implementation */
|
|
35
|
+
storage?: BlogPlatformStorage;
|
|
36
|
+
|
|
37
|
+
/** Custom rate limiting configuration */
|
|
38
|
+
rateLimit?: RateLimitConfig;
|
|
39
|
+
|
|
40
|
+
/** Whether to automatically configure rate limiting from environment */
|
|
41
|
+
autoRateLimit?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Initialize the blog platform with custom configuration
|
|
46
|
+
*
|
|
47
|
+
* This should be called once during your app startup, typically in:
|
|
48
|
+
* - Next.js: middleware.ts or layout.tsx
|
|
49
|
+
* - Express: app startup
|
|
50
|
+
* - Other frameworks: main entry point
|
|
51
|
+
*
|
|
52
|
+
* @param config - Configuration options (auth is REQUIRED)
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* ```typescript
|
|
56
|
+
* // Environment-based API key authentication
|
|
57
|
+
* initBlogPlatform({
|
|
58
|
+
* auth: new EnvAuth(), // Reads from BLOG_PLATFORM_API_KEYS
|
|
59
|
+
* });
|
|
60
|
+
*
|
|
61
|
+
* // Custom environment variable
|
|
62
|
+
* initBlogPlatform({
|
|
63
|
+
* auth: new EnvAuth("MY_BLOG_API_KEYS"),
|
|
64
|
+
* });
|
|
65
|
+
*
|
|
66
|
+
* // Custom authentication provider
|
|
67
|
+
* initBlogPlatform({
|
|
68
|
+
* auth: new CustomAuthProvider(),
|
|
69
|
+
* storage: new PostgresStorage(connectionString),
|
|
70
|
+
* rateLimit: {
|
|
71
|
+
* redisUrl: "https://...",
|
|
72
|
+
* redisToken: "...",
|
|
73
|
+
* requestsPerMinute: 10
|
|
74
|
+
* }
|
|
75
|
+
* });
|
|
76
|
+
*
|
|
77
|
+
* // Disable rate limiting for testing
|
|
78
|
+
* initBlogPlatform({
|
|
79
|
+
* auth: new NoAuth(), // Development only!
|
|
80
|
+
* autoRateLimit: false
|
|
81
|
+
* });
|
|
82
|
+
* ```
|
|
83
|
+
*/
|
|
84
|
+
export function initBlogPlatform(config: BlogPlatformConfig): void {
|
|
85
|
+
const {
|
|
86
|
+
auth,
|
|
87
|
+
storage,
|
|
88
|
+
rateLimit,
|
|
89
|
+
autoRateLimit = true,
|
|
90
|
+
} = config;
|
|
91
|
+
|
|
92
|
+
// Validate required configuration
|
|
93
|
+
if (!auth) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
"Blog platform initialization failed: 'auth' is required.\n" +
|
|
96
|
+
"Example: initBlogPlatform({ auth: new EnvAuth() })\n" +
|
|
97
|
+
"See https://github.com/xexr/blog-platform#authentication for more details."
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Set up authentication (REQUIRED)
|
|
102
|
+
console.log("Blog platform: Setting up authentication");
|
|
103
|
+
setAuthConfig(auth);
|
|
104
|
+
|
|
105
|
+
// Set up rate limiting
|
|
106
|
+
if (rateLimit) {
|
|
107
|
+
console.log("Blog platform: Using custom rate limiting configuration");
|
|
108
|
+
configureRateLimit(rateLimit);
|
|
109
|
+
} else if (autoRateLimit) {
|
|
110
|
+
console.log(
|
|
111
|
+
"Blog platform: Auto-configuring rate limiting from environment",
|
|
112
|
+
);
|
|
113
|
+
autoConfigureRateLimit();
|
|
114
|
+
} else {
|
|
115
|
+
console.log("Blog platform: Rate limiting disabled");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Set up storage
|
|
119
|
+
if (storage) {
|
|
120
|
+
console.log("Blog platform: Using custom storage provider");
|
|
121
|
+
setStorage(storage);
|
|
122
|
+
} else {
|
|
123
|
+
console.log(
|
|
124
|
+
"Blog platform: Using default in-memory storage (not suitable for production)",
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
console.log("Blog platform initialized successfully");
|
|
129
|
+
globalThis.__BLOG_PLATFORM_INITIALISED__ = true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Initialize the blog platform only once per process.
|
|
134
|
+
* Safe to call from multiple modules (e.g., layout + API routes).
|
|
135
|
+
*
|
|
136
|
+
* @param config - Configuration options (auth is REQUIRED)
|
|
137
|
+
*/
|
|
138
|
+
export function initBlogPlatformOnce(config: BlogPlatformConfig): void {
|
|
139
|
+
if (globalThis.__BLOG_PLATFORM_INITIALISED__) {
|
|
140
|
+
console.log("Blog platform: Already initialized, skipping");
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
console.log("Blog platform: Initializing blog platform once");
|
|
144
|
+
initBlogPlatform(config);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Quick setup for development/testing
|
|
149
|
+
* WARNING: Disables authentication - never use in production!
|
|
150
|
+
* Uses NoAuth (accepts all requests), in-memory storage, and disables rate limiting
|
|
151
|
+
*/
|
|
152
|
+
export function initBlogPlatformDev(): void {
|
|
153
|
+
initBlogPlatform({
|
|
154
|
+
auth: new NoAuth(),
|
|
155
|
+
autoRateLimit: false
|
|
156
|
+
});
|
|
157
|
+
console.log(
|
|
158
|
+
"Blog platform: Development mode enabled (no auth, in-memory storage, no rate limiting)",
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Production setup with environment-based API keys and rate limiting
|
|
164
|
+
* Requires BLOG_PLATFORM_API_KEYS (or custom env var), UPSTASH_REDIS_REST_URL,
|
|
165
|
+
* and UPSTASH_REDIS_REST_TOKEN environment variables
|
|
166
|
+
*
|
|
167
|
+
* @param apiKeysEnvVar - Optional custom environment variable name for API keys (default: BLOG_PLATFORM_API_KEYS)
|
|
168
|
+
*/
|
|
169
|
+
export function initBlogPlatformProd(apiKeysEnvVar?: string): void {
|
|
170
|
+
const envVar = apiKeysEnvVar ?? "BLOG_PLATFORM_API_KEYS";
|
|
171
|
+
|
|
172
|
+
if (!process.env[envVar]) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
`Missing required environment variable: ${envVar}. ` +
|
|
175
|
+
"Set this to a comma-separated list of API keys for blog platform authentication.",
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
initBlogPlatform({
|
|
180
|
+
auth: new EnvAuth(envVar),
|
|
181
|
+
autoRateLimit: true,
|
|
182
|
+
});
|
|
183
|
+
console.log(
|
|
184
|
+
"Blog platform: Production mode enabled (environment-based auth, rate limiting)",
|
|
185
|
+
);
|
|
186
|
+
}
|