AI tracker: owner-only Setup tab (per-provider connections, skills, instructions, plugins, routines, agents, plan/key labels, notes; ai.provider_setup_providers + provider_setup_entries, secrets refused)
Some checks failed
Deploy production / deploy (push) Failing after 1m3s
Some checks failed
Deploy production / deploy (push) Failing after 1m3s
This commit is contained in:
217
src/lib/server/ai-setup.ts
Normal file
217
src/lib/server/ai-setup.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
// AI tracker, Setup tab (owner-only): how each AI provider is set up. Cards are providers,
|
||||
// each with entries grouped by section. Labels only: values that look like secrets are refused.
|
||||
import { db } from '#lib/server/db/index.js';
|
||||
import {
|
||||
AI_SETUP_COLORS,
|
||||
AI_SETUP_SCOPES,
|
||||
AI_SETUP_SECTIONS,
|
||||
aiSetupEntries,
|
||||
aiSetupProviders
|
||||
} from '#lib/server/db/schema/index.js';
|
||||
import { asc, eq, sql } from 'drizzle-orm';
|
||||
import { Marked } from 'marked';
|
||||
import * as z from 'zod';
|
||||
|
||||
// Markdown for instructions/notes. Raw HTML is shown as text, never rendered.
|
||||
const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
||||
const marked = new Marked({
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
renderer: {
|
||||
html: ({ text }) => escapeHtml(text),
|
||||
link({ href, title, tokens }) {
|
||||
const label = this.parser.parseInline(tokens);
|
||||
if (!/^(https?:|mailto:|\/|#)/i.test(href)) return label;
|
||||
const t = title ? ` title="${escapeHtml(title)}"` : '';
|
||||
return `<a href="${escapeHtml(href)}"${t} rel="noopener noreferrer" target="_blank">${label}</a>`;
|
||||
},
|
||||
image: ({ text }) => escapeHtml(text)
|
||||
}
|
||||
});
|
||||
const renderBody = (md: string) => marked.parse(md, { async: false });
|
||||
|
||||
/** Common key formats. Anything matching is refused: this tab stores labels, never keys. */
|
||||
const SECRET =
|
||||
/(sk-(ant-|proj-)?[A-Za-z0-9_-]{16,}|xai-[A-Za-z0-9]{20,}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|AIza[0-9A-Za-z_-]{30,}|AKIA[0-9A-Z]{16}|xox[abpr]-[A-Za-z0-9-]{10,}|glpat-[A-Za-z0-9_-]{20,}|-----BEGIN [A-Z ]*PRIVATE KEY-----|eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.)/;
|
||||
const noSecret = (v: string | null | undefined) => !v || !SECRET.test(v);
|
||||
const SECRET_MSG = 'That looks like a key or token. Store a label only, never the value.';
|
||||
|
||||
const trimmed = (max: number) => z.string().trim().min(1, 'Name is required').max(max);
|
||||
const optional = (max: number) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.max(max)
|
||||
.nullish()
|
||||
.transform((v) => (v ? v : null));
|
||||
const idField = z.coerce.number().int().positive();
|
||||
|
||||
const providerInput = z.object({
|
||||
name: trimmed(60),
|
||||
products: optional(120),
|
||||
color: z.enum(AI_SETUP_COLORS).catch('zinc')
|
||||
});
|
||||
const entryInput = z
|
||||
.object({
|
||||
section: z.enum(AI_SETUP_SECTIONS),
|
||||
name: trimmed(120),
|
||||
note: optional(400),
|
||||
scope: z
|
||||
.enum(['', ...AI_SETUP_SCOPES])
|
||||
.nullish()
|
||||
.transform((v) => (v ? (v as (typeof AI_SETUP_SCOPES)[number]) : null)),
|
||||
link: optional(500).refine((u) => !u || /^https?:\/\/\S+$/i.test(u), 'Link must be http(s)'),
|
||||
body: optional(20000)
|
||||
})
|
||||
.refine((e) => noSecret(e.name) && noSecret(e.note) && noSecret(e.link) && noSecret(e.body), {
|
||||
message: SECRET_MSG
|
||||
});
|
||||
|
||||
export type SetupEntry = {
|
||||
id: number;
|
||||
section: (typeof AI_SETUP_SECTIONS)[number];
|
||||
name: string;
|
||||
note: string | null;
|
||||
scope: string | null;
|
||||
link: string | null;
|
||||
body: string | null;
|
||||
bodyHtml: string | null;
|
||||
updatedAt: string;
|
||||
};
|
||||
export type SetupProvider = {
|
||||
id: number;
|
||||
name: string;
|
||||
products: string | null;
|
||||
color: (typeof AI_SETUP_COLORS)[number];
|
||||
updatedAt: string;
|
||||
entries: SetupEntry[];
|
||||
};
|
||||
|
||||
export async function loadSetup(): Promise<SetupProvider[]> {
|
||||
const providers = await db
|
||||
.select()
|
||||
.from(aiSetupProviders)
|
||||
.orderBy(asc(aiSetupProviders.sortOrder), asc(aiSetupProviders.id));
|
||||
const entries = await db
|
||||
.select()
|
||||
.from(aiSetupEntries)
|
||||
.orderBy(asc(aiSetupEntries.sortOrder), asc(aiSetupEntries.id));
|
||||
return providers.map((p) => {
|
||||
const own = entries.filter((e) => e.providerId === p.id);
|
||||
const latest = Math.max(p.updatedAt.getTime(), ...own.map((e) => e.updatedAt.getTime()));
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
products: p.products,
|
||||
color: (AI_SETUP_COLORS as readonly string[]).includes(p.color)
|
||||
? (p.color as SetupProvider['color'])
|
||||
: 'zinc',
|
||||
updatedAt: new Date(latest).toISOString(),
|
||||
entries: own.map((e) => ({
|
||||
id: e.id,
|
||||
section: e.section as SetupEntry['section'],
|
||||
name: e.name,
|
||||
note: e.note,
|
||||
scope: e.scope,
|
||||
link: e.link,
|
||||
body: e.body,
|
||||
bodyHtml: e.body ? renderBody(e.body) : null,
|
||||
updatedAt: e.updatedAt.toISOString()
|
||||
}))
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
type Result = { ok: true } | { ok: false; message: string };
|
||||
const firstIssue = (e: z.ZodError) => e.issues[0]?.message ?? 'Invalid input';
|
||||
const fields = (form: FormData) =>
|
||||
Object.fromEntries([...form.entries()].map(([k, v]) => [k, typeof v === 'string' ? v : '']));
|
||||
|
||||
/** Entry changes count as a change to the card ("last updated" also covers deletes). */
|
||||
const touch = (providerId: number) =>
|
||||
db
|
||||
.update(aiSetupProviders)
|
||||
.set({ updatedAt: new Date() })
|
||||
.where(eq(aiSetupProviders.id, providerId));
|
||||
|
||||
const isUniqueViolation = (e: unknown) =>
|
||||
typeof e === 'object' &&
|
||||
e !== null &&
|
||||
('code' in e ? e.code : (e as { cause?: { code?: string } }).cause?.code) === '23505';
|
||||
|
||||
export async function addProvider(form: FormData): Promise<Result> {
|
||||
const parsed = providerInput.safeParse(fields(form));
|
||||
if (!parsed.success) return { ok: false, message: firstIssue(parsed.error) };
|
||||
try {
|
||||
const [{ next }] = await db
|
||||
.select({ next: sql<number>`coalesce(max(${aiSetupProviders.sortOrder}), 0) + 10` })
|
||||
.from(aiSetupProviders);
|
||||
await db.insert(aiSetupProviders).values({ ...parsed.data, sortOrder: Number(next) });
|
||||
} catch (e) {
|
||||
if (isUniqueViolation(e)) return { ok: false, message: 'A provider with that name exists' };
|
||||
throw e;
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function updateProvider(form: FormData): Promise<Result> {
|
||||
const id = idField.safeParse(form.get('id'));
|
||||
const parsed = providerInput.safeParse(fields(form));
|
||||
if (!id.success) return { ok: false, message: 'Missing id' };
|
||||
if (!parsed.success) return { ok: false, message: firstIssue(parsed.error) };
|
||||
try {
|
||||
await db.update(aiSetupProviders).set(parsed.data).where(eq(aiSetupProviders.id, id.data));
|
||||
} catch (e) {
|
||||
if (isUniqueViolation(e)) return { ok: false, message: 'A provider with that name exists' };
|
||||
throw e;
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deleteProvider(form: FormData): Promise<Result> {
|
||||
const id = idField.safeParse(form.get('id'));
|
||||
if (!id.success) return { ok: false, message: 'Missing id' };
|
||||
await db.delete(aiSetupProviders).where(eq(aiSetupProviders.id, id.data));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function addEntry(form: FormData): Promise<Result> {
|
||||
const providerId = idField.safeParse(form.get('provider_id'));
|
||||
const parsed = entryInput.safeParse(fields(form));
|
||||
if (!providerId.success) return { ok: false, message: 'Missing provider' };
|
||||
if (!parsed.success) return { ok: false, message: firstIssue(parsed.error) };
|
||||
const [{ next }] = await db
|
||||
.select({ next: sql<number>`coalesce(max(${aiSetupEntries.sortOrder}), 0) + 10` })
|
||||
.from(aiSetupEntries)
|
||||
.where(eq(aiSetupEntries.providerId, providerId.data));
|
||||
await db
|
||||
.insert(aiSetupEntries)
|
||||
.values({ ...parsed.data, providerId: providerId.data, sortOrder: Number(next) });
|
||||
await touch(providerId.data);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function updateEntry(form: FormData): Promise<Result> {
|
||||
const id = idField.safeParse(form.get('id'));
|
||||
const parsed = entryInput.safeParse(fields(form));
|
||||
if (!id.success) return { ok: false, message: 'Missing id' };
|
||||
if (!parsed.success) return { ok: false, message: firstIssue(parsed.error) };
|
||||
const [row] = await db
|
||||
.update(aiSetupEntries)
|
||||
.set(parsed.data)
|
||||
.where(eq(aiSetupEntries.id, id.data))
|
||||
.returning({ providerId: aiSetupEntries.providerId });
|
||||
if (row) await touch(row.providerId);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deleteEntry(form: FormData): Promise<Result> {
|
||||
const id = idField.safeParse(form.get('id'));
|
||||
if (!id.success) return { ok: false, message: 'Missing id' };
|
||||
const [row] = await db
|
||||
.delete(aiSetupEntries)
|
||||
.where(eq(aiSetupEntries.id, id.data))
|
||||
.returning({ providerId: aiSetupEntries.providerId });
|
||||
if (row) await touch(row.providerId);
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { check, date, doublePrecision, index, text, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import {
|
||||
check,
|
||||
date,
|
||||
doublePrecision,
|
||||
index,
|
||||
integer,
|
||||
text,
|
||||
uniqueIndex
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { aiSchema } from './schemas';
|
||||
import { id, timestamps, timestamptz } from './helpers';
|
||||
|
||||
@@ -82,3 +90,84 @@ export const aiUsageEntries = aiSchema.table(
|
||||
check('usage_entries_metric_check', sql`length(btrim(${t.metric})) > 0`)
|
||||
]
|
||||
);
|
||||
|
||||
/**
|
||||
* Setup tab (owner-only): how each AI provider is set up. One row per provider card; entries
|
||||
* are grouped into sections. Plan/API keys are labels only: the app refuses values that look
|
||||
* like a secret, and nothing here is ever a key value.
|
||||
*/
|
||||
export const AI_SETUP_SECTIONS = [
|
||||
'connections',
|
||||
'skills',
|
||||
'instructions',
|
||||
'plugins',
|
||||
'routines',
|
||||
'agents',
|
||||
'plan',
|
||||
'notes'
|
||||
] as const;
|
||||
export type AiSetupSection = (typeof AI_SETUP_SECTIONS)[number];
|
||||
export const AI_SETUP_SCOPES = ['global', 'per-repo', 'app'] as const;
|
||||
export type AiSetupScope = (typeof AI_SETUP_SCOPES)[number];
|
||||
export const AI_SETUP_COLORS = [
|
||||
'orange',
|
||||
'green',
|
||||
'blue',
|
||||
'violet',
|
||||
'pink',
|
||||
'sky',
|
||||
'amber',
|
||||
'zinc'
|
||||
] as const;
|
||||
|
||||
export const aiSetupProviders = aiSchema.table(
|
||||
'provider_setup_providers',
|
||||
{
|
||||
id: id(),
|
||||
name: text('name').notNull(),
|
||||
/** The products this card covers, e.g. "claude.ai + Claude Code". */
|
||||
products: text('products'),
|
||||
color: text('color').notNull().default('zinc'),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('provider_setup_providers_name_key').on(t.name),
|
||||
check('provider_setup_providers_name_check', sql`length(btrim(${t.name})) > 0`),
|
||||
check(
|
||||
'provider_setup_providers_color_check',
|
||||
sql`${t.color} IN ('orange','green','blue','violet','pink','sky','amber','zinc')`
|
||||
)
|
||||
]
|
||||
);
|
||||
|
||||
export const aiSetupEntries = aiSchema.table(
|
||||
'provider_setup_entries',
|
||||
{
|
||||
id: id(),
|
||||
providerId: integer('provider_id')
|
||||
.notNull()
|
||||
.references(() => aiSetupProviders.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
section: text('section').notNull(),
|
||||
name: text('name').notNull(),
|
||||
note: text('note'),
|
||||
scope: text('scope'),
|
||||
link: text('link'),
|
||||
/** Free markdown text (custom instructions, longer notes). */
|
||||
body: text('body'),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [
|
||||
index('provider_setup_entries_provider_idx').on(t.providerId, t.section, t.sortOrder),
|
||||
check(
|
||||
'provider_setup_entries_section_check',
|
||||
sql`${t.section} IN ('connections','skills','instructions','plugins','routines','agents','plan','notes')`
|
||||
),
|
||||
check(
|
||||
'provider_setup_entries_scope_check',
|
||||
sql`${t.scope} IS NULL OR ${t.scope} IN ('global','per-repo','app')`
|
||||
),
|
||||
check('provider_setup_entries_name_check', sql`length(btrim(${t.name})) > 0`)
|
||||
]
|
||||
);
|
||||
|
||||
@@ -29,9 +29,10 @@ export const ownerOnlyPrefixes = [
|
||||
'/trackers/books/files',
|
||||
// Notes (the Obsidian vault): pages, API, attachments. The /ws/notes socket checks the owner itself.
|
||||
'/trackers/notes',
|
||||
// AI tracker: the news tab (/trackers/ai) is public, bots and usage are mine only.
|
||||
// AI tracker: the news tab (/trackers/ai) is public, bots, usage and setup are mine only.
|
||||
'/trackers/ai/bots',
|
||||
'/trackers/ai/usage',
|
||||
'/trackers/ai/setup',
|
||||
'/lists/db',
|
||||
// Spotify OAuth for the listening tracker (connect + callback 404 for everyone else).
|
||||
'/api/spotify',
|
||||
|
||||
Reference in New Issue
Block a user