feat(FN-189): add CHANGELOG_AUTOMATION_TOKEN to env schema (+5 more)
Some checks failed
Sync dev → Gitea / Mirror dev to Gitea (push) Has been cancelled
Some checks failed
Sync dev → Gitea / Mirror dev to Gitea (push) Has been cancelled
Commits merged: - docs(FN-189): complete Step 7 — update docs/INDEX.md and MEMORY.md - fix(FN-189): lint fixes — import order, formatting - test(FN-189): complete Step 4 — add controller spec with 6 token auth scenarios - feat(FN-189): complete Step 3 — add Fusion changelog automation trigger to deploy workflow - feat(FN-189): complete Step 2 — add POST /api/changelog/internal endpoint with token auth - feat(FN-189): complete Step 1 — add CHANGELOG_AUTOMATION_TOKEN to env schema Files changed: .fusion/memory/MEMORY.md | 16 +++ .github/workflows/deploy.yml | 17 +++ .../api/src/changelog/changelog.controller.spec.ts | 122 +++++++++++++++++++++ apps/api/src/changelog/changelog.controller.ts | 49 ++++++++- docs/INDEX.md | 1 + packages/config/src/index.ts | 3 + 6 files changed, 207 insertions(+), 1 deletion(-) Fusion-Task-Id: FN-189
This commit is contained in:
@@ -48,3 +48,19 @@ Highlights to remember:
|
||||
- **Critical-path bans on dev:** see team-charter.md. Migrations + dep/config changes still need human ack; auth/payments fixes are auto on dev with QA Lead regression test.
|
||||
- **Critical-path bans on main:** strict — auth/payments/billing/subscription/migrations all gated.
|
||||
- **Code search:** Prefer `ast-grep` (`sg`) over plain `grep` for structural patterns. CLAUDE.md has the language flags and useful patterns.
|
||||
|
||||
## Features
|
||||
|
||||
### Changelog (FN-188)
|
||||
- New `changelog_entries` DB table (uuid PK, stage, title, description, publishedAt)
|
||||
- New NestJS module at `apps/api/src/changelog/` — public GET /api/changelog + admin CRUD
|
||||
- Redis-cached public endpoint (1800s TTL), cache invalidation on admin mutations
|
||||
- New shadcn/ui Accordion component in `@sase/ui`
|
||||
- Frontend: `useChangelog` hook + `ChangelogTab` component with timeline + accordion
|
||||
|
||||
### Changelog Automation (FN-189)
|
||||
- New `POST /api/changelog/internal` endpoint — token-authed via `CHANGELOG_AUTOMATION_TOKEN` env var (Bearer token, `crypto.timingSafeEqual`)
|
||||
- Endpoint is `@Public()` (bypasses Better Auth) with inline token validation; returns 503 if env not configured, 401 on auth failure, delegates to `ChangelogService.create()`
|
||||
- `CHANGELOG_AUTOMATION_TOKEN: z.string().min(32).optional()` added to `@sase/config` env schema
|
||||
- `.github/workflows/deploy.yml` has a dormant Fusion trigger step (Coolify deploys are current; step is ready when GHA is re-activated)
|
||||
- Controller spec (`changelog.controller.spec.ts`) covers 6 auth scenarios
|
||||
|
||||
17
.github/workflows/deploy.yml
vendored
17
.github/workflows/deploy.yml
vendored
@@ -59,3 +59,20 @@ jobs:
|
||||
pm2 reload ecosystem.config.js
|
||||
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') - Deployment complete!"
|
||||
|
||||
# NOTE: This trigger only fires when the GitHub Actions SSH-deploy workflow runs.
|
||||
# Current production deploys are Coolify-driven via git.semih.ai webhooks (see MEMORY.md).
|
||||
# When the GitHub Actions workflow is re-activated for production, this step will
|
||||
# automatically call Fusion to create a changelog entry after a successful deploy.
|
||||
- name: Trigger Fusion changelog automation
|
||||
if: success()
|
||||
continue-on-error: true
|
||||
run: |
|
||||
if [ -z "${{ secrets.FUSION_CHANGELOG_AUTOMATION_ID }}" ] || [ -z "${{ secrets.FUSION_DAEMON_TOKEN }}" ]; then
|
||||
echo "Fusion changelog secrets not configured — skipping automation trigger"
|
||||
exit 0
|
||||
fi
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: Bearer ${{ secrets.FUSION_DAEMON_TOKEN }}" \
|
||||
"https://fusion.semih.ai/api/automations/${{ secrets.FUSION_CHANGELOG_AUTOMATION_ID }}/run?projectId=proj_155fecc31ef14928" \
|
||||
|| echo "Fusion trigger failed (non-fatal, continuing)"
|
||||
|
||||
122
apps/api/src/changelog/changelog.controller.spec.ts
Normal file
122
apps/api/src/changelog/changelog.controller.spec.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { ServiceUnavailableException, UnauthorizedException } from "@nestjs/common";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ChangelogController } from "./changelog.controller";
|
||||
|
||||
// Mock crypto.timingSafeEqual for deterministic test control
|
||||
const mockTimingSafeEqual = vi.fn();
|
||||
vi.mock("node:crypto", () => ({
|
||||
timingSafeEqual: (...args: unknown[]) => mockTimingSafeEqual(...args),
|
||||
}));
|
||||
|
||||
function createMockService(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
create: vi.fn(),
|
||||
findAll: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const sampleEntry = {
|
||||
id: "entry-1",
|
||||
stage: "prod" as const,
|
||||
title: "Deployed v2.3.0",
|
||||
description: "Production deploy completed successfully.",
|
||||
publishedAt: "2026-05-11T12:00:00.000Z",
|
||||
createdAt: "2026-05-11T12:00:00.000Z",
|
||||
updatedAt: "2026-05-11T12:00:00.000Z",
|
||||
};
|
||||
|
||||
const VALID_TOKEN = "my-token-32-chars-minimum!!!";
|
||||
|
||||
describe("ChangelogController — createInternal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("token auth", () => {
|
||||
it("should return 503 when CHANGELOG_AUTOMATION_TOKEN env is not set", async () => {
|
||||
// Ensure the env var is unset
|
||||
vi.stubEnv("CHANGELOG_AUTOMATION_TOKEN", undefined as unknown as string);
|
||||
|
||||
const mockService = createMockService();
|
||||
const controller = new ChangelogController(mockService as any);
|
||||
|
||||
await expect(
|
||||
controller.createInternal("Bearer some-token", sampleEntry as any),
|
||||
).rejects.toThrow(ServiceUnavailableException);
|
||||
});
|
||||
|
||||
it("should return 401 when Authorization header is missing", async () => {
|
||||
vi.stubEnv("CHANGELOG_AUTOMATION_TOKEN", VALID_TOKEN);
|
||||
|
||||
const mockService = createMockService();
|
||||
const controller = new ChangelogController(mockService as any);
|
||||
|
||||
await expect(
|
||||
controller.createInternal(undefined as unknown as string, sampleEntry as any),
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it("should return 401 when Authorization has wrong prefix", async () => {
|
||||
vi.stubEnv("CHANGELOG_AUTOMATION_TOKEN", VALID_TOKEN);
|
||||
|
||||
const mockService = createMockService();
|
||||
const controller = new ChangelogController(mockService as any);
|
||||
|
||||
await expect(
|
||||
controller.createInternal("Basic some-token", sampleEntry as any),
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it("should return 401 when token is wrong", async () => {
|
||||
vi.stubEnv("CHANGELOG_AUTOMATION_TOKEN", VALID_TOKEN);
|
||||
|
||||
// Setup timingSafeEqual to return false (wrong token)
|
||||
mockTimingSafeEqual.mockReturnValue(false);
|
||||
|
||||
const mockService = createMockService();
|
||||
const controller = new ChangelogController(mockService as any);
|
||||
|
||||
await expect(
|
||||
controller.createInternal("Bearer wrong-token", sampleEntry as any),
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it("should return 201 and the created entry when token is valid", async () => {
|
||||
vi.stubEnv("CHANGELOG_AUTOMATION_TOKEN", VALID_TOKEN);
|
||||
|
||||
// Setup timingSafeEqual to return true (matching token)
|
||||
mockTimingSafeEqual.mockReturnValue(true);
|
||||
|
||||
const mockService = createMockService({
|
||||
create: vi.fn().mockResolvedValue(sampleEntry),
|
||||
});
|
||||
const controller = new ChangelogController(mockService as any);
|
||||
|
||||
const result = await controller.createInternal(`Bearer ${VALID_TOKEN}`, sampleEntry as any);
|
||||
|
||||
expect(mockService.create).toHaveBeenCalledWith(sampleEntry);
|
||||
expect(result).toEqual(sampleEntry);
|
||||
});
|
||||
});
|
||||
|
||||
describe("existing endpoints unaffected", () => {
|
||||
it("should still have the admin POST (create) endpoint that delegates to service", async () => {
|
||||
vi.stubEnv("CHANGELOG_AUTOMATION_TOKEN", VALID_TOKEN);
|
||||
|
||||
const mockService = createMockService({
|
||||
create: vi.fn().mockResolvedValue(sampleEntry),
|
||||
});
|
||||
const controller = new ChangelogController(mockService as any);
|
||||
|
||||
const result = await controller.create(sampleEntry as any);
|
||||
|
||||
expect(mockService.create).toHaveBeenCalledWith(sampleEntry);
|
||||
expect(result).toEqual(sampleEntry);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,17 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from "@nestjs/common";
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
ServiceUnavailableException,
|
||||
UnauthorizedException,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { Public } from "../common/decorators/public.decorator";
|
||||
import { Roles } from "../common/decorators/roles.decorator";
|
||||
import { RolesGuard } from "../common/guards/roles.guard";
|
||||
@@ -35,4 +48,38 @@ export class ChangelogController {
|
||||
async delete(@Param("id") id: string) {
|
||||
return this.changelogService.delete(id);
|
||||
}
|
||||
|
||||
@Post("internal")
|
||||
@Public()
|
||||
async createInternal(
|
||||
@Headers("authorization") authHeader: string,
|
||||
@Body() body: CreateChangelogEntry,
|
||||
) {
|
||||
// 1. Check if the automation token is configured
|
||||
const token = process.env.CHANGELOG_AUTOMATION_TOKEN;
|
||||
if (!token) {
|
||||
throw new ServiceUnavailableException("Changelog automation endpoint is not configured");
|
||||
}
|
||||
|
||||
// 2. Validate the Authorization header
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||
throw new UnauthorizedException("Missing or invalid Authorization header");
|
||||
}
|
||||
|
||||
// 3. Extract the token value
|
||||
const providedToken = authHeader.slice(7);
|
||||
|
||||
// 4. Constant-time comparison
|
||||
if (providedToken.length !== token.length) {
|
||||
throw new UnauthorizedException("Invalid token");
|
||||
}
|
||||
|
||||
const isMatch = timingSafeEqual(Buffer.from(providedToken), Buffer.from(token));
|
||||
if (!isMatch) {
|
||||
throw new UnauthorizedException("Invalid token");
|
||||
}
|
||||
|
||||
// 5. Create the entry
|
||||
return this.changelogService.create(body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,6 +509,7 @@ Instrumentation: Express, HTTP, ioredis, NestJS Core, BullMQ, Drizzle ORM
|
||||
|--------|------|------|-------------|
|
||||
| `GET` | `/api/changelog` | Public | List changelog entries (Redis-cached, 30min) |
|
||||
| `POST` | `/api/changelog` | Admin | Create changelog entry |
|
||||
| `POST` | `/api/changelog/internal` | Token (Bearer) | Create changelog entry (Fusion automation webhook, CHANGELOG_AUTOMATION_TOKEN env) |
|
||||
| `PATCH` | `/api/changelog/:id` | Admin | Update changelog entry |
|
||||
| `DELETE` | `/api/changelog/:id` | Admin | Delete changelog entry |
|
||||
|
||||
|
||||
@@ -76,6 +76,9 @@ export const envSchema = z.object({
|
||||
|
||||
// Sentry — error tracking
|
||||
SENTRY_DSN: z.string().url().optional(),
|
||||
|
||||
// Changelog automation (token-authed internal endpoint for Fusion webhook)
|
||||
CHANGELOG_AUTOMATION_TOKEN: z.string().min(32).optional(),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
|
||||
Reference in New Issue
Block a user