feat(links): /api/kutt proxy (version + create + recent)

This commit is contained in:
root
2026-06-08 23:28:07 +10:00
parent c8b9dddd61
commit cd5ca03d96
3 changed files with 79 additions and 0 deletions

View File

@@ -34,6 +34,7 @@ import { router as littleblueRouter } from './routes/littleblue.js';
import { router as aiUsageRouter } from './routes/ai_usage.js';
import { router as infraRouter } from './routes/infra.js';
import { router as clusterRouter } from './routes/cluster.js';
import { router as kuttRouter } from './routes/kutt.js';
export function mountApi(app) {
const api = Router();
@@ -65,6 +66,7 @@ export function mountApi(app) {
api.use('/conversations/:conversation_id/messages', messagesByConvRouter);
api.use('/tags', tagsRouter);
api.use('/links', linksRouter);
api.use('/kutt', kuttRouter);
api.use('/pending-changes', pendingChangesRouter);
api.use('/audit', auditRouter);
api.use('/search', searchRouter);

37
lib/api/routes/kutt.js Normal file
View File

@@ -0,0 +1,37 @@
import { Router } from 'express';
import { z } from 'zod';
import { asyncWrap } from '../errors.js';
import { requireOwner } from '../cap.js';
import { validate } from '../validate.js';
import { compareVersions, fetchLatestKuttRelease, createLink, recentLinks } from '../../links/kutt.js';
export const router = Router();
const cfg = () => ({ base: process.env.KUTT_API_URL, key: process.env.KUTT_API_KEY });
// GET /kutt/version — running (pinned env) vs latest GitHub release (cached 6h).
let cache = { at: 0, val: null };
router.get('/version', requireOwner, asyncWrap(async (_req, res) => {
const running = process.env.KUTT_VERSION || 'unknown';
if (Date.now() - cache.at > 6 * 3600e3 || !cache.val) {
try { cache = { at: Date.now(), val: await fetchLatestKuttRelease({}) }; }
catch { return res.json({ running, latest: null, updateAvailable: false, error: 'version check unavailable' }); }
}
res.json({ ...compareVersions(running, cache.val.latest), url: cache.val.url });
}));
const linkBody = z.object({
target: z.string().url(),
customurl: z.string().max(64).optional(),
description: z.string().max(200).optional()
});
// POST /kutt — create via Kutt (owner). Key stays server-side.
router.post('/', requireOwner, validate({ body: linkBody }), asyncWrap(async (req, res) => {
if (!process.env.KUTT_API_KEY) return res.status(502).json({ error: { code: 'kutt_unconfigured' } });
res.status(201).json(await createLink(req.body, cfg()));
}));
// GET /kutt/recent — last few links (owner).
router.get('/recent', requireOwner, asyncWrap(async (_req, res) => {
res.json(await recentLinks(cfg()));
}));

40
tests/api/kutt.test.js Normal file
View File

@@ -0,0 +1,40 @@
import { describe, it, expect, beforeAll, vi } from 'vitest';
import request from 'supertest';
vi.mock('../../lib/links/kutt.js', () => ({
compareVersions: (r, l) => ({ running: r, latest: l, updateAvailable: r !== l }),
fetchLatestKuttRelease: async () => ({ latest: 'v9.9.9', url: 'https://x' }),
createLink: async (b) => ({ link: 'https://link.hynesy.com/abc', address: 'abc', target: b.target }),
recentLinks: async () => ({ data: [] })
}));
let app;
const owner = r => r.set('Authorization', 'Bearer test-token');
beforeAll(async () => {
process.env.OWNER_TOKEN = 'test-token';
process.env.KUTT_API_URL = 'http://10.0.0.1:3000';
process.env.KUTT_API_KEY = 'K';
process.env.KUTT_VERSION = 'v3.2.5';
({ createApp } = await import('../../server.js'));
app = createApp();
});
let createApp;
describe('/api/kutt', () => {
it('GET /version returns running/latest/updateAvailable (owner)', async () => {
expect((await request(app).get('/api/kutt/version')).status).toBe(401);
const res = await owner(request(app).get('/api/kutt/version'));
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ running: 'v3.2.5', latest: 'v9.9.9', updateAvailable: true });
});
it('POST / creates a link via Kutt (owner)', async () => {
const res = await owner(request(app).post('/api/kutt')).send({ target: 'https://example.com' });
expect(res.status).toBe(201);
expect(res.body.link).toBe('https://link.hynesy.com/abc');
});
it('POST / rejects a non-URL target', async () => {
expect((await owner(request(app).post('/api/kutt')).send({ target: 'not a url' })).status).toBe(400);
});
});