Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
8b62012
init changelog route
madster456 Dec 21, 2025
903ea2c
init changelog parse
madster456 Dec 21, 2025
50bd17e
Update for new changelog
madster456 Dec 21, 2025
3b10389
Merge branch 'dev' into dashboard/changelog
madster456 Jan 8, 2026
5aad4e6
changelog in stack-companion
madster456 Jan 8, 2026
13f023a
Merge branch 'dev' into dashboard/changelog
madster456 Jan 8, 2026
ec8d86e
Merge branch 'dev' into dashboard/changelog
madster456 Jan 10, 2026
7d112df
Merge branch 'dev' into dashboard/changelog
madster456 Jan 14, 2026
3971834
cookie parsing and string comparing
madster456 Jan 14, 2026
74d3dfb
update properly typed props
madster456 Jan 14, 2026
0a30feb
Removed unused code
madster456 Jan 14, 2026
096fa26
abort controller, empty array handling, error propagation from parent
madster456 Jan 14, 2026
31e7e76
knosti's notes
madster456 Jan 22, 2026
3c250fc
Merge branch 'dev' into dashboard/changelog
madster456 Jan 27, 2026
5dfed8c
Move changelog fetching to backend
madster456 Jan 27, 2026
c6773ee
Ignore unreleased in changelog
madster456 Jan 27, 2026
030b8c5
Merge branch 'dev' into dashboard/changelog
madster456 Jan 27, 2026
872c3ba
Merge branch 'dev' into dashboard/changelog
madster456 Jan 27, 2026
da92216
Fixed type mistmatch
madster456 Jan 27, 2026
d1e4bd2
Merge branch 'dev' into dashboard/changelog
madster456 Jan 28, 2026
63cca4b
Merge branch 'dev' into dashboard/changelog
madster456 Jan 28, 2026
2cf1d0d
Merge branch 'dev' into dashboard/changelog
madster456 Jan 28, 2026
49f674a
Merge branch 'dev' into dashboard/changelog
madster456 Jan 28, 2026
d69f72f
Merge branch 'dev' into dashboard/changelog
madster456 Jan 28, 2026
c7cf181
Merge branch 'dev' into dashboard/changelog
madster456 Jan 29, 2026
1542480
Merge branch 'dev' into dashboard/changelog
madster456 Jan 29, 2026
ae6b94b
Merge branch 'dev' into dashboard/changelog
madster456 Jan 29, 2026
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
2 changes: 2 additions & 0 deletions apps/backend/.env.development
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ NEXT_PUBLIC_STACK_API_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}0
NEXT_PUBLIC_STACK_DASHBOARD_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}01
STACK_SERVER_SECRET=23-wuNpik0gIW4mruTz25rbIvhuuvZFrLOLtL7J4tyo

STACK_CHANGELOG_URL=https://raw.githubusercontent.com/stack-auth/stack-auth/refs/heads/dev/CHANGELOG.md

STACK_SEED_ENABLE_DUMMY_PROJECT=true
STACK_SEED_INTERNAL_PROJECT_SIGN_UP_ENABLED=true
STACK_SEED_INTERNAL_PROJECT_OTP_ENABLED=true
Expand Down
179 changes: 179 additions & 0 deletions apps/backend/src/app/api/latest/internal/changelog/route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";

const REVALIDATE_SECONDS = 60 * 60;

type ChangeType = "major" | "minor" | "patch";

type ChangelogEntry = {
version: string,
type: ChangeType,
markdown: string,
bulletCount: number,
releasedAt?: string,
isUnreleased?: boolean,
};

type TaggedBullet = { text: string, tags: string[] };

function parseTaggedBullet(line: string): TaggedBullet {
let content = line.replace(/^- /, "").trim();
const tags: string[] = [];

while (content.startsWith("[")) {
const closingIndex = content.indexOf("]");
if (closingIndex === -1) break;

const tag = content.slice(1, closingIndex).trim();
if (!tag) break;

tags.push(tag);
content = content.slice(closingIndex + 1).trim();
}

return { text: content, tags };
}

function parseVersionHeading(raw: string): { version: string, releasedAt?: string, isUnreleased: boolean } {
const normalized = raw.trim();
const isUnreleased = normalized.toLowerCase() === "unreleased";

if (isUnreleased) {
return { version: "Unreleased", isUnreleased: true };
}

const datePattern = /^(\d+\.\d+\.\d+)\s*(?:\(|-)\s*(\d{4}-\d{2}-\d{2})\)?$/;
const match = normalized.match(datePattern);

if (match) {
return {
version: match[1],
releasedAt: match[2],
isUnreleased: false,
};
}

return {
version: normalized,
isUnreleased: false,
};
}

function parseRootChangelog(markdown: string): ChangelogEntry[] {
const entries: ChangelogEntry[] = [];
const sections = markdown.split(/(?=^## .+)/m);

for (const section of sections) {
if (!section.trim()) continue;

const versionMatch = section.match(/^## (.+)/m);
if (!versionMatch) continue;

const heading = versionMatch[1].trim();
const { version, releasedAt, isUnreleased } = parseVersionHeading(heading);
const isSemver = /^\d+\.\d+\.\d+$/.test(version);
const isCalVer = /^\d{4}\.\d{2}\.\d{2}$/.test(version);

if (!isUnreleased && !isSemver && !isCalVer) {
continue;
}

const versionContent = section.replace(/^## .+$/m, "").trim();

let type: ChangeType = "patch";
if (versionContent.includes("### Major Changes")) type = "major";
else if (versionContent.includes("### Minor Changes")) type = "minor";

const lines = versionContent.split("\n");
const processedLines: string[] = [];

for (const line of lines) {
if (line.trim().startsWith("- ")) {
const { text } = parseTaggedBullet(line);
processedLines.push(text ? `- ${text}` : "-");
} else {
processedLines.push(line);
}
}

const normalizedMarkdown = processedLines.join("\n").trim();
const bulletCount = processedLines.filter(l => l.trim().startsWith("-")).length;

entries.push({
version,
type,
markdown: normalizedMarkdown,
bulletCount,
isUnreleased,
releasedAt,
});
}

return entries;
}

const changelogEntrySchema = yupObject({
version: yupString().defined(),
type: yupString().oneOf(["major", "minor", "patch"]).defined(),
markdown: yupString().defined(),
bulletCount: yupNumber().defined(),
releasedAt: yupString().optional(),
isUnreleased: yupBoolean().optional(),
}).defined();

export const GET = createSmartRouteHandler({
metadata: {
hidden: true,
},
request: yupObject({
method: yupString().oneOf(["GET"]).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200, 502]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
entries: yupArray(changelogEntrySchema).optional(),
error: yupString().optional(),
}).defined(),
}),
handler: async () => {
const changelogUrl = getEnvVariable("STACK_CHANGELOG_URL", "");

if (!changelogUrl) {
return {
statusCode: 200,
bodyType: "json",
body: { entries: [] },
} as const;
}

const response = await fetch(changelogUrl, {
headers: {
"Accept": "text/plain",
"User-Agent": "stack-auth-backend-changelog",
},
next: {
revalidate: REVALIDATE_SECONDS,
},
});

if (!response.ok) {
return {
statusCode: 502,
bodyType: "json",
body: { error: "Failed to download changelog" },
} as const;
}

const content = await response.text();
const entries = parseRootChangelog(content).slice(0, 8);

return {
statusCode: 200,
bodyType: "json",
body: { entries },
} as const;
},
});

1 change: 1 addition & 0 deletions apps/dashboard/.env
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ STACK_DEVELOPMENT_TRANSLATION_LOCALE=# enter the locale to use for the translati
NEXT_PUBLIC_STACK_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS='["internal"]'
NEXT_PUBLIC_STACK_DEBUGGER_ON_ASSERTION_ERROR=# set to true to open the debugger on assertion errors (set to true in .env.development)
STACK_FEATUREBASE_JWT_SECRET=# used for Featurebase SSO, you probably won't have to set this
STACK_CHANGELOG_URL=# Used for raw github link to root changelog.md file.
131 changes: 128 additions & 3 deletions apps/dashboard/src/components/stack-companion.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,51 @@
'use client';

import { Button, Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui';
import { ChangelogEntry } from '@/lib/changelog';
import { getPublicEnvVar } from '@/lib/env';
import { cn } from '@/lib/utils';
import { checkVersion, VersionCheckResult } from '@/lib/version-check';
import { BookOpenIcon, CircleNotchIcon, ClockClockwiseIcon, LightbulbIcon, XIcon } from '@phosphor-icons/react';
import { runAsynchronously } from '@stackframe/stack-shared/dist/utils/promises';
import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
import packageJson from '../../package.json';
import { FeedbackForm } from './feedback-form';
import { ChangelogWidget } from './stack-companion/changelog-widget';
import { FeatureRequestBoard } from './stack-companion/feature-request-board';
import { UnifiedDocsWidget } from './stack-companion/unified-docs-widget';

/**
* Compare two CalVer versions in YYYY.MM.DD format
* Returns true if version1 is newer than version2
*/
function isNewerCalVer(version1: string, version2: string): boolean {
const parseCalVer = (version: string): Date | null => {
const match = version.match(/^(\d{4})\.(\d{2})\.(\d{2})$/);
if (!match) return null;
const [, year, month, day] = match;
return new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
};

const date1 = parseCalVer(version1);
const date2 = parseCalVer(version2);

if (!date1 || !date2) {
// Fallback to string comparison if parsing fails
return version1 > version2;
}

return date1.getTime() > date2.getTime();
}

/**
* Sanitize a string value for use in a cookie
* Removes or encodes characters that could break cookie parsing
*/
function sanitizeCookieValue(value: string): string {
// Remove or encode special characters that break cookie parsing
return encodeURIComponent(value);
}

type SidebarItem = {
id: string,
label: string,
Expand Down Expand Up @@ -73,6 +108,7 @@ export function useStackCompanion() {
return useContext(StackCompanionContext);
}


export function StackCompanion({ className }: { className?: string }) {
const [activeItem, setActiveItem] = useState<string | null>(null);
const [mounted, setMounted] = useState(false);
Expand All @@ -82,6 +118,9 @@ export function StackCompanion({ className }: { className?: string }) {
const [isAnimating, setIsAnimating] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [isSplitScreenMode, setIsSplitScreenMode] = useState(false);
const [changelogData, setChangelogData] = useState<ChangelogEntry[] | undefined>(undefined);
const [hasNewVersions, setHasNewVersions] = useState(false);
const [lastSeenVersion, setLastSeenVersion] = useState('');

const startXRef = useRef(0);
const startWidthRef = useRef(0);
Expand Down Expand Up @@ -125,6 +164,84 @@ export function StackCompanion({ className }: { className?: string }) {
return cleanup;
}, []);

// Fetch changelog data on mount and check for new versions
useEffect(() => {
runAsynchronously(async () => {
const baseUrl = getPublicEnvVar('NEXT_PUBLIC_STACK_API_URL') || '';
const response = await fetch(`${baseUrl}/api/latest/internal/changelog`);
if (!response.ok) {
return;
}

const payload = await response.json();
const entries = payload.entries || [];
setChangelogData(entries);

// Check for new versions
const lastSeenRaw = document.cookie
.split('; ')
.find(row => row.startsWith('stack-last-seen-changelog-version='))
?.split('=')[1] || '';

const lastSeen = lastSeenRaw ? decodeURIComponent(lastSeenRaw) : '';
setLastSeenVersion(lastSeen);

if (entries.length > 0) {
// If no lastSeen cookie, user hasn't seen any changelog yet - show bell
if (!lastSeen) {
setHasNewVersions(true);
} else {
const hasNewer = entries.some((entry: ChangelogEntry) => {
if (entry.isUnreleased) return false;
return isNewerCalVer(entry.version, lastSeen);
});
setHasNewVersions(hasNewer);
}
}
});
}, []);

// Re-check for new versions when changelog is opened/closed
useEffect(() => {
if (activeItem === 'changelog') {
// When changelog is opened, mark the latest released version as seen
// Skip unreleased versions to avoid breaking version comparison
if (changelogData && changelogData.length > 0) {
const latestReleasedEntry = changelogData.find(entry => !entry.isUnreleased);
if (latestReleasedEntry) {
document.cookie = `stack-last-seen-changelog-version=${sanitizeCookieValue(latestReleasedEntry.version)}; path=/; max-age=31536000`; // 1 year
setLastSeenVersion(latestReleasedEntry.version);
}
}
// Clear the notification badge immediately
setHasNewVersions(false);
} else if (activeItem === null) {
// When closed, re-check if there are new versions
const lastSeenRaw = document.cookie
.split('; ')
.find(row => row.startsWith('stack-last-seen-changelog-version='))
?.split('=')[1] || '';

const lastSeen = lastSeenRaw ? decodeURIComponent(lastSeenRaw) : '';

if (changelogData && changelogData.length > 0) {
// If no lastSeen cookie, user hasn't seen any changelog yet - show bell
if (!lastSeen) {
setHasNewVersions(true);
} else {
const hasNewer = changelogData.some((entry: ChangelogEntry) => {
if (entry.isUnreleased) return false;
return isNewerCalVer(entry.version, lastSeen);
});
setHasNewVersions(hasNewer);
}
} else {
setHasNewVersions(false);
}
}
}, [activeItem, changelogData]);


const openDrawer = useCallback((itemId: string) => {
setActiveItem(itemId);
setIsAnimating(true);
Expand Down Expand Up @@ -304,7 +421,7 @@ export function StackCompanion({ className }: { className?: string }) {
<div className="flex-1 overflow-y-auto p-5 overflow-x-hidden no-drag cursor-auto">
{activeItem === 'docs' && <UnifiedDocsWidget isActive={true} />}
{activeItem === 'feedback' && <FeatureRequestBoard isActive={true} />}
{activeItem === 'changelog' && <ChangelogWidget isActive={true} />}
{activeItem === 'changelog' && <ChangelogWidget isActive={true} initialData={changelogData} />}
{activeItem === 'support' && <FeedbackForm />}
</div>
</div>
Expand Down Expand Up @@ -338,18 +455,26 @@ export function StackCompanion({ className }: { className?: string }) {
className={cn(
"h-10 w-10 p-0 text-muted-foreground transition-all duration-[50ms] rounded-xl relative group",
item.hoverBg,
activeItem === item.id && "bg-foreground/10 text-foreground shadow-sm ring-1 ring-foreground/5"
activeItem === item.id && "bg-foreground/10 text-foreground shadow-sm ring-1 ring-foreground/5",
// Glow effect for changelog with new updates
item.id === 'changelog' && hasNewVersions && "ring-2 ring-green-500/30 bg-green-500/10"
)}
onClick={(e) => {
e.stopPropagation();
handleItemClick(item.id);
}}
>
<item.icon className={cn("h-5 w-5 transition-transform duration-[50ms] group-hover:scale-110", item.color)} />
{item.id === 'changelog' && hasNewVersions && (
<span className="absolute -top-1 -right-1 flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500" />
</span>
)}
</Button>
</TooltipTrigger>
<TooltipContent side="left" className="z-[60] mr-2">
{item.label}
{item.id === 'changelog' && hasNewVersions ? `${item.label} (New updates available!)` : item.label}
</TooltipContent>
</Tooltip>
))}
Expand Down
Loading
Loading