@natesena/blog-lib 0.1.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,132 @@
1
+ import {
2
+ GCSStorage
3
+ } from "../chunk-OPJV2ECE.js";
4
+
5
+ // src/server/auth/config.ts
6
+ var blogAuthConfig = null;
7
+ function configureBlogAuth(config) {
8
+ blogAuthConfig = config;
9
+ }
10
+ async function getBlogAuthCurrentUser() {
11
+ if (!blogAuthConfig) {
12
+ return null;
13
+ }
14
+ return blogAuthConfig.getCurrentUser();
15
+ }
16
+ function isBlogAuthConfigured() {
17
+ return blogAuthConfig !== null;
18
+ }
19
+
20
+ // src/server/auth/permissions.ts
21
+ function canAccessCMS(user) {
22
+ const adminEmailsCsv = process.env.CMS_ADMIN_EMAILS;
23
+ if (adminEmailsCsv) {
24
+ const allowedAdminEmails = adminEmailsCsv.split(",").map((email) => email.trim().toLowerCase());
25
+ const normalizedUserEmail = user.email.trim().toLowerCase();
26
+ return allowedAdminEmails.includes(normalizedUserEmail);
27
+ }
28
+ if (user.isAdmin !== void 0) {
29
+ return user.isAdmin;
30
+ }
31
+ if (process.env.NODE_ENV === "development") {
32
+ console.warn(
33
+ "[blog-lib] canAccessCMS: No CMS_ADMIN_EMAILS env var set and user.isAdmin is undefined. Set CMS_ADMIN_EMAILS=email@example.com or pass isAdmin: true to grant CMS access."
34
+ );
35
+ }
36
+ return false;
37
+ }
38
+
39
+ // src/server/auth/api-key.ts
40
+ import { headers } from "next/headers";
41
+ function verifyApiKey(providedApiKey) {
42
+ const configuredApiKey = process.env.CMS_API_KEY;
43
+ if (!configuredApiKey) {
44
+ return false;
45
+ }
46
+ return providedApiKey === configuredApiKey;
47
+ }
48
+ async function checkApiKeyAuth() {
49
+ const requestHeaders = await headers();
50
+ const providedApiKey = requestHeaders.get("x-api-key");
51
+ return verifyApiKey(providedApiKey);
52
+ }
53
+
54
+ // src/server/auth/middleware.ts
55
+ import { redirect } from "next/navigation";
56
+ async function requireCMSAuth(user, signInRedirectUrl = "/sign-in") {
57
+ if (!user) {
58
+ redirect(signInRedirectUrl);
59
+ }
60
+ if (!canAccessCMS(user)) {
61
+ throw new Error("Unauthorized: insufficient CMS permissions");
62
+ }
63
+ }
64
+
65
+ // src/server/actions/upload.ts
66
+ var ALLOWED_IMAGE_MIME_TYPES = [
67
+ "image/jpeg",
68
+ "image/png",
69
+ "image/webp",
70
+ "image/gif"
71
+ ];
72
+ var MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
73
+ async function initiateImageUpload(input) {
74
+ if (isBlogAuthConfigured()) {
75
+ const authenticatedUser = await getBlogAuthCurrentUser();
76
+ if (!authenticatedUser) {
77
+ throw new Error("Authentication required. Configure auth with configureBlogAuth().");
78
+ }
79
+ }
80
+ if (!ALLOWED_IMAGE_MIME_TYPES.includes(input.fileType)) {
81
+ throw new Error(
82
+ `Invalid file type "${input.fileType}". Allowed: ${ALLOWED_IMAGE_MIME_TYPES.join(", ")}`
83
+ );
84
+ }
85
+ if (input.fileSizeBytes > MAX_FILE_SIZE_BYTES) {
86
+ throw new Error(
87
+ `File too large (${Math.round(input.fileSizeBytes / 1024 / 1024)}MB). Maximum: 10MB.`
88
+ );
89
+ }
90
+ const timestamp = Date.now();
91
+ const randomSuffix = Math.random().toString(36).substring(2, 8);
92
+ const fileExtension = input.fileName.split(".").pop() || "jpg";
93
+ const destinationFilename = input.folder ? `${input.folder}/${timestamp}-${randomSuffix}.${fileExtension}` : `${timestamp}-${randomSuffix}.${fileExtension}`;
94
+ const gcsStorage = new GCSStorage({
95
+ projectId: process.env.GCP_PROJECT_ID,
96
+ bucket: process.env.GCS_BUCKET,
97
+ keyFile: process.env.GCP_KEYFILE
98
+ });
99
+ const { uploadUrl, publicUrl } = await gcsStorage.getPresignedUploadUrl(
100
+ destinationFilename,
101
+ input.fileType
102
+ );
103
+ return { uploadUrl, publicUrl, destinationFilename };
104
+ }
105
+ async function confirmImageUpload(destinationFilename) {
106
+ if (isBlogAuthConfigured()) {
107
+ const authenticatedUser = await getBlogAuthCurrentUser();
108
+ if (!authenticatedUser) {
109
+ throw new Error("Authentication required. Configure auth with configureBlogAuth().");
110
+ }
111
+ }
112
+ const gcsStorage = new GCSStorage({
113
+ projectId: process.env.GCP_PROJECT_ID,
114
+ bucket: process.env.GCS_BUCKET,
115
+ keyFile: process.env.GCP_KEYFILE
116
+ });
117
+ const isUploadConfirmed = await gcsStorage.exists(destinationFilename);
118
+ if (!isUploadConfirmed) {
119
+ throw new Error("File not found in storage after upload");
120
+ }
121
+ return { isUploadConfirmed };
122
+ }
123
+ export {
124
+ canAccessCMS,
125
+ checkApiKeyAuth,
126
+ configureBlogAuth,
127
+ confirmImageUpload,
128
+ initiateImageUpload,
129
+ requireCMSAuth,
130
+ verifyApiKey
131
+ };
132
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/server/auth/config.ts","../../src/server/auth/permissions.ts","../../src/server/auth/api-key.ts","../../src/server/auth/middleware.ts","../../src/server/actions/upload.ts"],"sourcesContent":["/**\n * Pluggable Auth Configuration\n *\n * Allows consumers to inject their own auth provider into blog-lib.\n * Upload actions and other protected operations call getCurrentUser()\n * and reject requests if no user is returned.\n *\n * @example\n * import { configureBlogAuth } from '@nate/blog-lib/server';\n *\n * configureBlogAuth({\n * getCurrentUser: async () => {\n * const session = await auth(); // your auth provider\n * return session?.user ?? null;\n * },\n * });\n *\n * Ref: Plan Phase 3, Step 3.2\n */\n\nexport interface BlogAuthUser {\n id: string;\n email?: string;\n}\n\nexport interface BlogAuthConfig {\n /**\n * Return the currently authenticated user, or null if unauthenticated.\n * Called by upload actions and other protected operations.\n */\n getCurrentUser: () => Promise<BlogAuthUser | null>;\n}\n\nlet blogAuthConfig: BlogAuthConfig | null = null;\n\n/**\n * Configure blog-lib's auth integration. Call once during app initialization.\n */\nexport function configureBlogAuth(config: BlogAuthConfig): void {\n blogAuthConfig = config;\n}\n\n/**\n * Get the currently authenticated user using the configured auth provider.\n * Returns null if no auth provider is configured or no user is authenticated.\n *\n * @internal Used by upload actions and other protected operations.\n */\nexport async function getBlogAuthCurrentUser(): Promise<BlogAuthUser | null> {\n if (!blogAuthConfig) {\n return null;\n }\n return blogAuthConfig.getCurrentUser();\n}\n\n/**\n * Check if blog auth has been configured.\n */\nexport function isBlogAuthConfigured(): boolean {\n return blogAuthConfig !== null;\n}\n","/**\n * Auth Permission Utilities\n *\n * Pluggable permission checks for CMS access.\n * Works with any auth system — Clerk, Auth0, NextAuth.js, or custom.\n *\n * Ref: docs/epic-blog-lib/02-architecture.md (lines 308-324)\n * Ref: docs/epic-blog-lib/04-auth-guide.md\n * Ref: docs/epic-blog-lib/03-tasks.md (Task openclaw-n1y.7)\n */\n\nexport interface AuthUser {\n id: string;\n email: string;\n isAdmin?: boolean;\n}\n\n/**\n * Check if a user has permission to access the CMS.\n *\n * Uses CMS_ADMIN_EMAILS env var (comma-separated) to restrict access.\n * If no env var is set, all authenticated users are allowed.\n */\nexport function canAccessCMS(user: AuthUser): boolean {\n const adminEmailsCsv = process.env.CMS_ADMIN_EMAILS;\n\n if (adminEmailsCsv) {\n const allowedAdminEmails = adminEmailsCsv.split(',').map((email) => email.trim().toLowerCase());\n const normalizedUserEmail = user.email.trim().toLowerCase();\n return allowedAdminEmails.includes(normalizedUserEmail);\n }\n\n // If isAdmin is explicitly set, use it\n if (user.isAdmin !== undefined) {\n return user.isAdmin;\n }\n\n // Default deny — CMS_ADMIN_EMAILS must be set or isAdmin must be true\n // Ref: Plan Phase 3, Step 3.3\n if (process.env.NODE_ENV === 'development') {\n console.warn(\n '[blog-lib] canAccessCMS: No CMS_ADMIN_EMAILS env var set and user.isAdmin is undefined. ' +\n 'Set CMS_ADMIN_EMAILS=email@example.com or pass isAdmin: true to grant CMS access.',\n );\n }\n return false;\n}\n","/**\n * Simple API Key Auth\n *\n * For headless CMS access or simple auth without a full auth provider.\n * Set CMS_API_KEY env var and send it in the x-api-key header.\n *\n * Ref: docs/epic-blog-lib/02-architecture.md (lines 646-671)\n * Ref: docs/epic-blog-lib/03-tasks.md (Task openclaw-n1y.7)\n */\n\nimport { headers } from 'next/headers';\n\n/**\n * Verify an API key against the CMS_API_KEY environment variable.\n * Returns false if CMS_API_KEY is not configured.\n */\nexport function verifyApiKey(providedApiKey?: string | null): boolean {\n const configuredApiKey = process.env.CMS_API_KEY;\n\n if (!configuredApiKey) {\n return false; // API key auth not configured\n }\n\n return providedApiKey === configuredApiKey;\n}\n\n/**\n * Check the incoming request's x-api-key header against CMS_API_KEY.\n * Use in server components or API routes.\n */\nexport async function checkApiKeyAuth(): Promise<boolean> {\n const requestHeaders = await headers();\n const providedApiKey = requestHeaders.get('x-api-key');\n return verifyApiKey(providedApiKey);\n}\n","/**\n * Auth Middleware Helper\n *\n * Convenience function to enforce auth in server components.\n * Redirects unauthenticated users and checks CMS permissions.\n *\n * Ref: docs/epic-blog-lib/03-tasks.md (Task openclaw-n1y.7)\n */\n\nimport { redirect } from 'next/navigation';\nimport { canAccessCMS } from './permissions';\nimport type { AuthUser } from './permissions';\n\n/**\n * Require authenticated CMS access. Redirects to signInUrl if no user,\n * throws Error if user lacks CMS permission.\n *\n * @example\n * const user = await getYourAuthUser();\n * await requireCMSAuth(user, '/sign-in');\n */\nexport async function requireCMSAuth(\n user: AuthUser | null,\n signInRedirectUrl: string = '/sign-in',\n): Promise<void> {\n if (!user) {\n redirect(signInRedirectUrl);\n }\n\n if (!canAccessCMS(user)) {\n throw new Error('Unauthorized: insufficient CMS permissions');\n }\n}\n","/**\n * Image Upload Server Actions\n *\n * Server-side actions for initiating and confirming image uploads to GCS.\n * Frontend calls these, then uploads directly to GCS using the presigned URL.\n *\n * Ref: docs/epic-blog-lib/02-architecture.md (lines 927-991)\n * Ref: docs/epic-blog-lib/03-tasks.md (Task openclaw-n1y.10)\n */\n\n'use server';\n\nimport { GCSStorage } from '../../sdk/storage/gcs';\nimport { getBlogAuthCurrentUser, isBlogAuthConfigured } from '../auth/config';\n\nconst ALLOWED_IMAGE_MIME_TYPES = [\n 'image/jpeg',\n 'image/png',\n 'image/webp',\n 'image/gif',\n];\n\nconst MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10MB\n\nexport interface InitiateUploadInput {\n fileName: string;\n fileType: string;\n fileSizeBytes: number;\n folder?: string;\n}\n\nexport interface InitiateUploadResult {\n uploadUrl: string;\n publicUrl: string;\n destinationFilename: string;\n}\n\n/**\n * Validate the file and generate a presigned GCS upload URL.\n * The client then uploads directly to GCS using this URL.\n */\nexport async function initiateImageUpload(\n input: InitiateUploadInput,\n): Promise<InitiateUploadResult> {\n // Auth check — require authenticated user if auth is configured\n // Ref: Plan Phase 3, Step 3.2\n if (isBlogAuthConfigured()) {\n const authenticatedUser = await getBlogAuthCurrentUser();\n if (!authenticatedUser) {\n throw new Error('Authentication required. Configure auth with configureBlogAuth().');\n }\n }\n\n // Validate file type\n if (!ALLOWED_IMAGE_MIME_TYPES.includes(input.fileType)) {\n throw new Error(\n `Invalid file type \"${input.fileType}\". Allowed: ${ALLOWED_IMAGE_MIME_TYPES.join(', ')}`,\n );\n }\n\n // Validate file size\n if (input.fileSizeBytes > MAX_FILE_SIZE_BYTES) {\n throw new Error(\n `File too large (${Math.round(input.fileSizeBytes / 1024 / 1024)}MB). Maximum: 10MB.`,\n );\n }\n\n // Generate unique destination filename\n const timestamp = Date.now();\n const randomSuffix = Math.random().toString(36).substring(2, 8);\n const fileExtension = input.fileName.split('.').pop() || 'jpg';\n const destinationFilename = input.folder\n ? `${input.folder}/${timestamp}-${randomSuffix}.${fileExtension}`\n : `${timestamp}-${randomSuffix}.${fileExtension}`;\n\n // Initialize GCS and generate presigned URL\n const gcsStorage = new GCSStorage({\n projectId: process.env.GCP_PROJECT_ID!,\n bucket: process.env.GCS_BUCKET!,\n keyFile: process.env.GCP_KEYFILE,\n });\n\n const { uploadUrl, publicUrl } = await gcsStorage.getPresignedUploadUrl(\n destinationFilename,\n input.fileType,\n );\n\n return { uploadUrl, publicUrl, destinationFilename };\n}\n\n/**\n * Confirm that a file was successfully uploaded to GCS.\n * Call after the client finishes the direct upload.\n */\nexport async function confirmImageUpload(\n destinationFilename: string,\n): Promise<{ isUploadConfirmed: boolean }> {\n // Auth check — require authenticated user if auth is configured\n if (isBlogAuthConfigured()) {\n const authenticatedUser = await getBlogAuthCurrentUser();\n if (!authenticatedUser) {\n throw new Error('Authentication required. Configure auth with configureBlogAuth().');\n }\n }\n\n const gcsStorage = new GCSStorage({\n projectId: process.env.GCP_PROJECT_ID!,\n bucket: process.env.GCS_BUCKET!,\n keyFile: process.env.GCP_KEYFILE,\n });\n\n const isUploadConfirmed = await gcsStorage.exists(destinationFilename);\n if (!isUploadConfirmed) {\n throw new Error('File not found in storage after upload');\n }\n\n return { isUploadConfirmed };\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,93 @@
1
+ {
2
+ "name": "@natesena/blog-lib",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "private": false,
6
+ "description": "Universal blog library for Next.js — SDK, Components, and CMS UI with pluggable auth",
7
+ "keywords": [
8
+ "blog",
9
+ "nextjs",
10
+ "cms",
11
+ "prisma",
12
+ "postgresql"
13
+ ],
14
+ "author": "Nate",
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "https://github.com/natesena/blog-lib"
19
+ },
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/index.d.ts",
23
+ "import": "./dist/index.js"
24
+ },
25
+ "./sdk": {
26
+ "types": "./dist/sdk/index.d.ts",
27
+ "import": "./dist/sdk/index.js"
28
+ },
29
+ "./components": {
30
+ "types": "./dist/components/index.d.ts",
31
+ "import": "./dist/components/index.js"
32
+ },
33
+ "./server": {
34
+ "types": "./dist/server/index.d.ts",
35
+ "import": "./dist/server/index.js"
36
+ }
37
+ },
38
+ "files": [
39
+ "dist"
40
+ ],
41
+ "scripts": {
42
+ "build:lib": "tsup",
43
+ "dev:lib": "tsup --watch",
44
+ "prepublishOnly": "npm run build:lib",
45
+ "lint": "eslint . --max-warnings 0",
46
+ "lint:fix": "eslint . --fix",
47
+ "format": "prettier --write .",
48
+ "format:check": "prettier --check .",
49
+ "type-check": "tsc --noEmit",
50
+ "test": "jest",
51
+ "test:watch": "jest --watch",
52
+ "prisma:generate": "prisma generate",
53
+ "prisma:migrate": "prisma migrate dev",
54
+ "prisma:studio": "prisma studio"
55
+ },
56
+ "dependencies": {
57
+ "class-variance-authority": "^0.7.1",
58
+ "clsx": "^2.1.1",
59
+ "isomorphic-dompurify": "^3.0.0-rc.2",
60
+ "slugify": "^1.6.6",
61
+ "tailwind-merge": "^3.4.0"
62
+ },
63
+ "devDependencies": {
64
+ "@google-cloud/storage": "^7.19.0",
65
+ "@tailwindcss/postcss": "^4",
66
+ "@types/node": "^20",
67
+ "@types/react": "^19",
68
+ "@types/react-dom": "^19",
69
+ "esbuild-plugin-react18": "^0.2.6",
70
+ "eslint": "^9",
71
+ "eslint-config-next": "16.1.6",
72
+ "prettier": "^3.8.1",
73
+ "prisma": "^6.19.2",
74
+ "tailwindcss": "^4",
75
+ "tsup": "^8.5.1",
76
+ "typescript": "^5"
77
+ },
78
+ "peerDependencies": {
79
+ "@prisma/client": "^6.0.0",
80
+ "next": ">=15.0.0",
81
+ "react": ">=18.0.0",
82
+ "react-dom": ">=18.0.0"
83
+ },
84
+ "peerDependenciesMeta": {
85
+ "@prisma/client": {
86
+ "optional": true
87
+ }
88
+ },
89
+ "engines": {
90
+ "node": ">=18.0.0",
91
+ "npm": ">=9.0.0"
92
+ }
93
+ }