Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/web/components/page-settings/integrations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,10 @@ export default function IntegrationsSettings({
<div className="md:col-span-1">
<div className="px-4 sm:px-0">
<h3 className="text-lg font-medium leading-6 text-gray-900 dark:text-gray-50">
Zapier and GitHub actions
CLI, Zapier and GitHub Actions
</h3>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
Automate your work across 5,000+ apps.
Use the CLI or automate your work across 5,000+ apps.
</p>
</div>
</div>
Expand Down
112 changes: 112 additions & 0 deletions apps/web/pages/api/v1/posts/[id].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { supabaseAdmin } from "@changespage/supabase/admin";
import { PostStatus } from "@changespage/supabase/types/page";
import { NewPostSchema } from "../../../../data/schema";
import { withSecretKey } from "../../../../utils/withSecretKey";
import { IPublicPost, POST_SELECT_FIELDS } from "./shared";

export default withSecretKey<IPublicPost>(async (req, res, { page }) => {
const { id } = req.query;

if (!id || typeof id !== "string") {
return res
.status(400)
.json({ error: { statusCode: 400, message: "Missing post id" } });
}

if (req.method === "GET") {
const { data, error } = await supabaseAdmin
.from("posts")
.select(POST_SELECT_FIELDS)
.eq("id", id)
.eq("page_id", page.id)
.single();

if (error) {
if (error.code === "PGRST116") {
return res
.status(404)
.json({ error: { statusCode: 404, message: "Post not found" } });
}
return res
.status(500)
.json({ error: { statusCode: 500, message: error.message } });
}

return res.status(200).json(data);
}

if (req.method === "PATCH") {
const UPDATABLE_FIELDS = ["title", "content", "tags", "status", "publish_at", "allow_reactions", "notes"] as const;

const updates: Record<string, unknown> = {};
for (const field of UPDATABLE_FIELDS) {
if (req.body[field] !== undefined) updates[field] = req.body[field];
}

if (updates.status === PostStatus.published) {
updates.publication_date = new Date().toISOString();
}

if (Object.keys(updates).length === 0) {
return res
.status(400)
.json({ error: { statusCode: 400, message: "No fields to update" } });
}

try {
const partialSchema = NewPostSchema.pick(Object.keys(updates) as (keyof typeof NewPostSchema.fields)[]);
await partialSchema.validate(updates);
} catch (validationError: unknown) {
return res.status(400).json({
error: {
statusCode: 400,
message:
validationError instanceof Error
? validationError.message
: "Validation failed",
},
});
}

const { data, error } = await supabaseAdmin
.from("posts")
.update(updates)
.eq("id", id)
.eq("page_id", page.id)
.select(POST_SELECT_FIELDS)
.single();

if (error) {
if (error.code === "PGRST116") {
return res
.status(404)
.json({ error: { statusCode: 404, message: "Post not found" } });
}
return res
.status(500)
.json({ error: { statusCode: 500, message: error.message } });
}

return res.status(200).json(data);
}

if (req.method === "DELETE") {
const { error } = await supabaseAdmin
.from("posts")
.delete()
.eq("id", id)
.eq("page_id", page.id);

if (error) {
return res
.status(500)
.json({ error: { statusCode: 500, message: error.message } });
}

return res.status(204).end();
}

return res
.status(405)
.json({ error: { statusCode: 405, message: "Method not allowed" } });
});
96 changes: 96 additions & 0 deletions apps/web/pages/api/v1/posts/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { supabaseAdmin } from "@changespage/supabase/admin";
import { PostStatus } from "@changespage/supabase/types/page";
import { v4 } from "uuid";
import { NewPostSchema } from "../../../../data/schema";
import { withSecretKey } from "../../../../utils/withSecretKey";
import { IPublicPost, POST_SELECT_FIELDS } from "./shared";

export default withSecretKey<IPublicPost | IPublicPost[]>(async (req, res, { page }) => {
if (req.method === "GET") {
const { status, limit: rawLimit = "20", offset: rawOffset = "0" } = req.query;

const limit = Math.max(1, Number(rawLimit) || 20);
const offset = Math.max(0, Number(rawOffset) || 0);

let query = supabaseAdmin
.from("posts")
.select(POST_SELECT_FIELDS)
.eq("page_id", page.id)
.order("created_at", { ascending: false })
.range(offset, offset + limit - 1);

if (status && typeof status === "string") {
if (!Object.values(PostStatus).includes(status as PostStatus)) {
return res
.status(400)
.json({ error: { statusCode: 400, message: "Invalid status" } });
}
query = query.eq("status", status as PostStatus);
}

const { data, error } = await query;

if (error) {
return res
.status(500)
.json({ error: { statusCode: 500, message: error.message } });
}

return res.status(200).json(data);
}

if (req.method === "POST") {
const { title, tags, content, status, publish_at, allow_reactions, notes } =
req.body;

const images_folder = v4();
const publication_date =
status === PostStatus.published ? new Date().toISOString() : null;

const postData = {
title,
content,
tags: tags ?? [],
status: status ?? PostStatus.draft,
page_id: page.id,
user_id: page.user_id,
images_folder,
publication_date,
publish_at: publish_at ?? null,
allow_reactions: allow_reactions ?? false,
email_notified: false,
notes: notes ?? null,
};

try {
await NewPostSchema.validate(postData);
} catch (validationError: unknown) {
return res.status(400).json({
error: {
statusCode: 400,
message:
validationError instanceof Error
? validationError.message
: "Validation failed",
},
});
}

const { data, error } = await supabaseAdmin
.from("posts")
.insert([postData])
.select(POST_SELECT_FIELDS);

if (error) {
return res
.status(500)
.json({ error: { statusCode: 500, message: error.message } });
}

return res.status(201).json(data[0]);
}

return res
.status(405)
.json({ error: { statusCode: 405, message: "Method not allowed" } });
});
6 changes: 6 additions & 0 deletions apps/web/pages/api/v1/posts/shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { IPost } from "@changespage/supabase/types/page";

export type IPublicPost = Omit<IPost, "user_id" | "page_id" | "images_folder" | "email_notified">;

export const POST_SELECT_FIELDS =
"id, title, content, tags, status, notes, allow_reactions, publish_at, publication_date, created_at, updated_at";
42 changes: 42 additions & 0 deletions apps/web/utils/withSecretKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { IErrorResponse } from "@changespage/supabase/types/api";
import { IPage } from "@changespage/supabase/types/page";
import type { NextApiRequest, NextApiResponse } from "next";
import { getPageByIntegrationSecret } from "./useDatabase";

type SecretKeyHandler<T = unknown> = (
req: NextApiRequest,
res: NextApiResponse<T | IErrorResponse>,
{ page }: { page: IPage },
) => Promise<unknown> | void;

export function withSecretKey<T = unknown>(handler: SecretKeyHandler<T>) {
return async (
req: NextApiRequest,
res: NextApiResponse<T | IErrorResponse>,
) => {
try {
const secretKey = req.headers["page-secret-key"];

if (!secretKey) {
return res.status(401).json({
error: { statusCode: 401, message: "Missing page-secret-key header" },
});
}

const page = await getPageByIntegrationSecret(String(secretKey));
Comment on lines +18 to +26
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

page-secret-key header value is not guarded against an array.

req.headers["page-secret-key"] is typed string | string[] | undefined. A non-empty array is truthy, bypassing the !secretKey check. String(["key"]) then produces "key" for a single-element array, but String(["a","b"]) produces "a,b", which won't match any secret. Add an explicit array guard for robustness.

🛡️ Proposed fix
     const secretKey = req.headers["page-secret-key"];

-    if (!secretKey) {
+    if (!secretKey || Array.isArray(secretKey)) {
       return res.status(401).json({
         error: { statusCode: 401, message: "Missing page-secret-key header" },
       });
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const secretKey = req.headers["page-secret-key"];
if (!secretKey) {
return res.status(401).json({
error: { statusCode: 401, message: "Missing page-secret-key header" },
});
}
const page = await getPageByIntegrationSecret(String(secretKey));
const secretKey = req.headers["page-secret-key"];
if (!secretKey || Array.isArray(secretKey)) {
return res.status(401).json({
error: { statusCode: 401, message: "Missing page-secret-key header" },
});
}
const page = await getPageByIntegrationSecret(String(secretKey));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/utils/withSecretKey.ts` around lines 18 - 26, The code reads
req.headers["page-secret-key"] into secretKey but doesn't guard against
string[]; add an explicit array check (Array.isArray(secretKey)) and reject
arrays (return res.status(401).json({ error: { statusCode: 401, message:
"page-secret-key must be a single value" }})) before converting to String and
calling getPageByIntegrationSecret; update the branch around the secretKey
variable and the call to getPageByIntegrationSecret to only run when secretKey
is a single string.


if (!page) {
return res.status(401).json({
error: { statusCode: 401, message: "Invalid secret key" },
});
}

return handler(req, res, { page });
} catch (error) {
console.error("Secret key auth error:", error);
return res.status(500).json({
error: { statusCode: 500, message: "Internal server error" },
});
}
Comment on lines +35 to +40
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Catch-all handler returns 401 for all errors, masking internal server failures.

If getPageByIntegrationSecret throws due to a database/network outage, the client receives 401 "Invalid secret key" instead of 500. This makes incidents much harder to diagnose. Reserve the catch for unexpected auth-layer errors and return 500 for non-auth failures.

🛡️ Proposed fix
     } catch (error) {
       console.error("Secret key auth error:", error);
-      return res.status(401).json({
-        error: { statusCode: 401, message: "Invalid secret key" },
-      });
+      return res.status(500).json({
+        error: { statusCode: 500, message: "Internal server error" },
+      });
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch (error) {
console.error("Secret key auth error:", error);
return res.status(401).json({
error: { statusCode: 401, message: "Invalid secret key" },
});
}
} catch (error) {
console.error("Secret key auth error:", error);
return res.status(500).json({
error: { statusCode: 500, message: "Internal server error" },
});
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/utils/withSecretKey.ts` around lines 35 - 40, The catch-all in the
withSecretKey middleware currently maps every failure to a 401; update the error
handling in the try/catch around getPageByIntegrationSecret so only
authentication failures produce res.status(401).json(...): if
getPageByIntegrationSecret signals an invalid secret (e.g., returns
null/undefined or throws a specific AuthError you already use), return 401;
otherwise treat other exceptions (DB/network/internal) as server errors by
logging full error details via console.error and returning
res.status(500).json({ error: { statusCode: 500, message: "Internal server
error" } }). Ensure you reference the getPageByIntegrationSecret call and the
withSecretKey middleware/res to apply this branching logic, using error.name or
instanceof to distinguish auth errors where appropriate.

};
}
96 changes: 96 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# @changespage/cli

CLI for [changes.page](https://changes.page) — manage posts from the terminal.

## Install

```bash
npm install -g @changespage/cli
```

## Setup

```bash
chp configure
```

You'll be prompted to enter your page secret key. Find it in your page settings under **Integrations**.

Alternatively, use the `CHANGESPAGE_SECRET_KEY` environment variable or `--secret-key` flag.

## Usage

### List posts

```bash
chp posts list
chp posts list --status published --limit 5
```

### Get a post

```bash
chp posts get <id>
```

### Create a post

Content is read from stdin:

```bash
echo "Release notes here" | chp posts create --title "v2.0" --tags new,fix --status draft
```

Or from a file:

```bash
chp posts create --title "v2.0" --tags new,fix --status published < content.md
```

### Update a post

```bash
echo "Updated content" | chp posts update <id> --title "v2.1" --tags improvement
```

Update metadata only (no stdin pipe):

```bash
chp posts update <id> --status published
```

### Delete a post

```bash
chp posts delete <id>
```

## Options

| Flag | Description |
|---|---|
| `--secret-key <key>` | Page secret key |
| `--pretty` | Pretty-print JSON output |

### List options

| Flag | Description |
|---|---|
| `--status <status>` | Filter by status: `draft`, `published`, `archived` |
| `--limit <n>` | Max number of posts (default: `20`) |
| `--offset <n>` | Offset for pagination (default: `0`) |

### Create / Update options

| Flag | Description |
|---|---|
| `--title <title>` | Post title (required for create) |
| `--tags <tags>` | Comma-separated tags: `new`, `fix`, `improvement`, `announcement`, `alert` |
| `--status <status>` | `draft`, `published`, `archived` (default: `draft`) |
| `--publish-at <date>` | ISO date for scheduled publish |
| `--allow-reactions` / `--no-allow-reactions` | Enable or disable reactions |
| `--notes <notes>` | Internal notes |

## License

MIT
Loading