From cd5ca03d96c177cf4f539b4b40dd855cc7e7bcb2 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 8 Jun 2026 23:28:07 +1000 Subject: [PATCH] feat(links): /api/kutt proxy (version + create + recent) --- lib/api/index.js | 2 ++ lib/api/routes/kutt.js | 37 +++++++++++++++++++++++++++++++++++++ tests/api/kutt.test.js | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+) create mode 100644 lib/api/routes/kutt.js create mode 100644 tests/api/kutt.test.js diff --git a/lib/api/index.js b/lib/api/index.js index 2999840..71d68a9 100644 --- a/lib/api/index.js +++ b/lib/api/index.js @@ -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); diff --git a/lib/api/routes/kutt.js b/lib/api/routes/kutt.js new file mode 100644 index 0000000..adc46b5 --- /dev/null +++ b/lib/api/routes/kutt.js @@ -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())); +})); diff --git a/tests/api/kutt.test.js b/tests/api/kutt.test.js new file mode 100644 index 0000000..b977c12 --- /dev/null +++ b/tests/api/kutt.test.js @@ -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); + }); +});