feat(admin): live redis kill-switch on tunnel actions, sortable columns + CSV export + bulk actions, Node 24 LTS

WS1: pin all Docker stages to node:24.16.0-alpine; add engines node>=20.
WS2: lib/redis.ts gains TTL-backed redisSet, redisDel, setTunnelActive (writes tunnel:active:<sub>=1/0 EX 30, TUNNEL_ACTIVE_TTL override, no-op without REDIS_URL); wired into tunnel active/delete/reassign routes.
WS3: sortable columns, CSV export routes (token excluded), and bulk actions (self-account guard) across users/tunnels/audit admin tables.
This commit is contained in:
Gerhard Scheikl
2026-05-31 14:46:22 +02:00
parent 1adb6e7b3f
commit d317e8c758
22 changed files with 1296 additions and 173 deletions
+38
View File
@@ -0,0 +1,38 @@
/**
* Minimal, dependency-free CSV serialization for the admin export endpoints.
* RFC-4180 style: fields containing a comma, double-quote, CR or LF are wrapped
* in double-quotes with embedded quotes doubled. Everything is coerced to a
* string first; null/undefined become empty fields.
*/
export function csvField(v: unknown): string {
let s: string;
if (v === null || v === undefined) s = '';
else if (typeof v === 'string') s = v;
else if (typeof v === 'object') s = JSON.stringify(v);
else s = String(v);
if (/[",\r\n]/.test(s)) {
return `"${s.replace(/"/g, '""')}"`;
}
return s;
}
export function csvRow(values: unknown[]): string {
return values.map(csvField).join(',');
}
/**
* Build a full CSV document (header + rows). A leading row is always the
* provided header. Lines are CRLF-terminated for maximal spreadsheet
* compatibility.
*/
export function toCsv(header: string[], rows: unknown[][]): string {
const lines = [csvRow(header), ...rows.map(csvRow)];
return lines.join('\r\n') + '\r\n';
}
/**
* Max rows any single export will emit. Keeps a runaway export bounded; the
* caller notes the cap in the report when hit.
*/
export const EXPORT_MAX_ROWS = 10000;
+102 -9
View File
@@ -1,5 +1,15 @@
import type { User } from '@supabase/supabase-js';
import { getSupabaseAdmin } from '@/lib/supabase/admin';
import {
parseOrder,
parseSort,
USER_SORTS,
TUNNEL_SORTS,
AUDIT_SORTS,
type SortOrder,
type UserSort,
type TunnelSort,
} from '@/lib/admin/sort';
/**
* Shared admin list-query logic, used by BOTH the JSON route handlers and the
@@ -66,23 +76,56 @@ type TunnelRow = TunnelJoinRow & {
const USER_SCAN_MAX_PAGES = 50;
const USER_SCAN_PER_PAGE = 1000;
function userSortValue(u: User, sort: UserSort): string | number {
switch (sort) {
case 'email':
return (u.email ?? '').toLowerCase();
case 'role':
return ((u.app_metadata?.role as string | undefined) ?? 'user').toLowerCase();
case 'last_sign_in_at':
return u.last_sign_in_at ? Date.parse(u.last_sign_in_at) : 0;
case 'created_at':
default:
return u.created_at ? Date.parse(u.created_at) : 0;
}
}
function sortUsers(arr: User[], sort: UserSort, order: SortOrder): void {
const dir = order === 'asc' ? 1 : -1;
arr.sort((a, b) => {
const av = userSortValue(a, sort);
const bv = userSortValue(b, sort);
if (av < bv) return -1 * dir;
if (av > bv) return 1 * dir;
return 0;
});
}
export async function getUsersList(opts: {
page: number;
perPage: number;
search: string;
sort?: string | null;
order?: string | null;
}): Promise<{ users: AdminUserItem[]; total: number }> {
const { page, perPage } = opts;
const search = opts.search.trim().toLowerCase();
const sort = parseSort(opts.sort, USER_SORTS, 'created_at');
const order = parseOrder(opts.order, 'desc');
const admin = getSupabaseAdmin();
let pageUsers: User[];
let total: number;
if (search) {
// Search must match across ALL users, not just the current listUsers page.
// A non-default sort must order across ALL users (not just one listUsers
// page), so it shares the search path's full directory scan.
const isDefaultSort = sort === 'created_at' && order === 'desc';
const needScan = !!search || !isDefaultSort;
if (needScan) {
// Page through the user directory (bounded by USER_SCAN_MAX_PAGES),
// accumulate case-insensitive email substring matches, then paginate the
// filtered set.
// accumulate case-insensitive email substring matches, sort, then
// paginate the filtered+sorted set.
const matched: User[] = [];
for (let p = 1; p <= USER_SCAN_MAX_PAGES; p++) {
const { data, error } = await admin.auth.admin.listUsers({
@@ -93,15 +136,18 @@ export async function getUsersList(opts: {
const us = data.users;
if (us.length === 0) break;
for (const u of us) {
if ((u.email ?? '').toLowerCase().includes(search)) matched.push(u);
if (!search || (u.email ?? '').toLowerCase().includes(search)) {
matched.push(u);
}
}
if (us.length < USER_SCAN_PER_PAGE) break;
}
sortUsers(matched, sort, order);
total = matched.length;
const from = (page - 1) * perPage;
pageUsers = matched.slice(from, from + perPage);
} else {
// Common no-search path: cheap single-page lookup (unchanged behavior).
// Common no-search, default-sort path: cheap single-page lookup.
const { data, error } = await admin.auth.admin.listUsers({ page, perPage });
if (error) throw new Error(error.message);
pageUsers = data.users;
@@ -146,14 +192,54 @@ export async function getUsersList(opts: {
return { users, total };
}
function tunnelSortValue(t: TunnelRow, sort: TunnelSort): string | number {
switch (sort) {
case 'subdomain':
return t.subdomain.toLowerCase();
case 'bytes_used':
return t.bytes_used;
case 'quota_bytes':
return t.quota_bytes;
case 'is_active':
return t.is_active ? 1 : 0;
case 'last_seen_at':
return t.last_seen_at ? Date.parse(t.last_seen_at) : 0;
case 'usage_pct':
return t.quota_bytes > 0 ? t.bytes_used / t.quota_bytes : 0;
case 'created_at':
default:
return t.created_at ? Date.parse(t.created_at) : 0;
}
}
function sortTunnelRows(
arr: TunnelRow[],
sort: TunnelSort,
ascending: boolean,
): void {
const dir = ascending ? 1 : -1;
arr.sort((a, b) => {
const av = tunnelSortValue(a, sort);
const bv = tunnelSortValue(b, sort);
if (av < bv) return -1 * dir;
if (av > bv) return 1 * dir;
return 0;
});
}
export async function getTunnelsList(opts: {
page: number;
perPage: number;
search: string;
status: string | null;
sort?: string | null;
order?: string | null;
}): Promise<{ tunnels: TunnelItem[]; total: number }> {
const { page, perPage, status } = opts;
const search = opts.search.trim().toLowerCase();
const sort = parseSort(opts.sort, TUNNEL_SORTS, 'created_at');
const order = parseOrder(opts.order, 'desc');
const ascending = order === 'asc';
const admin = getSupabaseAdmin();
// The tunnels table's primary key is user_id (one tunnel per user); there is
@@ -171,11 +257,12 @@ export async function getTunnelsList(opts: {
// so fetch matching rows and paginate in memory.
let q = admin.from('tunnels').select(cols);
if (search) q = q.ilike('subdomain', `%${search}%`);
const { data, error } = await q.order('created_at', { ascending: false });
const { data, error } = await q;
if (error) throw new Error(error.message);
const all = ((data ?? []) as TunnelRow[]).filter(
(t) => t.quota_bytes > 0 && t.bytes_used >= t.quota_bytes,
);
sortTunnelRows(all, sort, ascending);
total = all.length;
rows = all.slice(from, from + perPage);
} else {
@@ -183,7 +270,9 @@ export async function getTunnelsList(opts: {
if (search) query = query.ilike('subdomain', `%${search}%`);
if (status === 'active') query = query.eq('is_active', true);
else if (status === 'inactive') query = query.eq('is_active', false);
query = query.order('created_at', { ascending: false }).range(from, to);
// usage_pct is computed; order by bytes_used as its closest DB proxy.
const dbCol = sort === 'usage_pct' ? 'bytes_used' : sort;
query = query.order(dbCol, { ascending }).range(from, to);
const { data, error, count } = await query;
if (error) throw new Error(error.message);
@@ -226,10 +315,14 @@ export async function getAuditList(opts: {
perPage: number;
action: string;
targetType: string;
sort?: string | null;
order?: string | null;
}): Promise<{ entries: AuditItem[]; total: number }> {
const { page, perPage } = opts;
const action = opts.action.trim();
const targetType = opts.targetType.trim();
const sort = parseSort(opts.sort, AUDIT_SORTS, 'created_at');
const ascending = parseOrder(opts.order, 'desc') === 'asc';
const admin = getSupabaseAdmin();
let query = admin
@@ -243,7 +336,7 @@ export async function getAuditList(opts: {
const from = (page - 1) * perPage;
const to = from + perPage - 1;
query = query.order('created_at', { ascending: false }).range(from, to);
query = query.order(sort, { ascending }).range(from, to);
const { data, error, count } = await query;
if (error) throw new Error(error.message);
+45
View File
@@ -0,0 +1,45 @@
/**
* Tiny helpers to parse + whitelist server-side sort parameters for the admin
* list/export endpoints. Anything not on the per-endpoint allow-list falls
* back to that endpoint's default column, so untrusted `sort`/`order` query
* params can never reach PostgREST verbatim.
*/
export type SortOrder = 'asc' | 'desc';
export function parseOrder(
v: string | null | undefined,
fallback: SortOrder = 'desc',
): SortOrder {
return v === 'asc' || v === 'desc' ? v : fallback;
}
export function parseSort<T extends string>(
v: string | null | undefined,
allowed: readonly T[],
fallback: T,
): T {
return v && (allowed as readonly string[]).includes(v) ? (v as T) : fallback;
}
export const USER_SORTS = [
'email',
'created_at',
'last_sign_in_at',
'role',
] as const;
export type UserSort = (typeof USER_SORTS)[number];
export const TUNNEL_SORTS = [
'subdomain',
'bytes_used',
'quota_bytes',
'is_active',
'created_at',
'last_seen_at',
'usage_pct',
] as const;
export type TunnelSort = (typeof TUNNEL_SORTS)[number];
export const AUDIT_SORTS = ['created_at', 'action', 'actor_email'] as const;
export type AuditSort = (typeof AUDIT_SORTS)[number];
+78 -7
View File
@@ -41,10 +41,11 @@ function encodeCommand(args: string[]): string {
}
/**
* Best-effort SET. Resolves true on apparent success, false otherwise.
* Never rejects.
* Best-effort execution of a single RESP command (preceded by optional
* AUTH/SELECT). Resolves true when the final reply is a non-error (`+...` or
* `:...`), false otherwise. Never rejects.
*/
export function redisSet(key: string, value: string): Promise<boolean> {
function runCommand(args: string[]): Promise<boolean> {
const url = process.env.REDIS_URL;
if (!url) return Promise.resolve(false);
const target = parseRedisUrl(url);
@@ -68,7 +69,7 @@ export function redisSet(key: string, value: string): Promise<boolean> {
if (target.db !== undefined && target.db > 0) {
commands.push(encodeCommand(['SELECT', String(target.db)]));
}
commands.push(encodeCommand(['SET', key, value]));
commands.push(encodeCommand(args));
const socket = net.createConnection(
{ host: target.host, port: target.port },
@@ -85,16 +86,86 @@ export function redisSet(key: string, value: string): Promise<boolean> {
socket.on('error', () => done(false));
let buf = '';
let expectedReplies = commands.length;
const expectedReplies = commands.length;
socket.on('data', (chunk) => {
buf += chunk.toString('utf8');
// Count complete simple replies (lines terminated by \r\n).
const lines = buf.split('\r\n').filter((l) => l.length > 0);
if (lines.length >= expectedReplies) {
const last = lines[lines.length - 1];
// +OK for SET success.
done(last.startsWith('+OK') || last.startsWith('+'));
// +OK / +... (simple string) or :N (integer, e.g. DEL count) = success.
done(last.startsWith('+') || last.startsWith(':'));
}
});
});
}
/**
* Best-effort SET. When `ttlSeconds` is a positive integer the command is
* issued as `SET key val EX <ttl>`, otherwise a plain `SET key val`. Resolves
* true on apparent success, false otherwise. Never rejects. Backward
* compatible: existing two-arg callers are unaffected.
*/
export function redisSet(
key: string,
value: string,
ttlSeconds?: number,
): Promise<boolean> {
const args = ['SET', key, value];
if (
typeof ttlSeconds === 'number' &&
Number.isFinite(ttlSeconds) &&
ttlSeconds > 0
) {
args.push('EX', String(Math.floor(ttlSeconds)));
}
return runCommand(args);
}
/**
* Best-effort DEL. Resolves true when Redis acknowledges the command (reply is
* an integer, even 0), false otherwise. Never rejects.
*/
export function redisDel(key: string): Promise<boolean> {
return runCommand(['DEL', key]);
}
/**
* TTL (seconds) written alongside the tunnel:active flag. Must match the edge
* `tunnel-active` forward_auth gate's TTL_SECONDS (currently 30) so the flag
* naturally expires in lock-step with the gate's cache. Overridable via
* TUNNEL_ACTIVE_TTL; defaults to 30.
*/
function tunnelActiveTtl(): number {
const raw = process.env.TUNNEL_ACTIVE_TTL;
if (raw) {
const n = Number(raw);
if (Number.isFinite(n) && n > 0) return Math.floor(n);
}
return 30;
}
/**
* Live kill-switch primitive. Writes the SAME key the edge gate reads:
* SET tunnel:active:<subdomain> <"1"|"0"> EX <TTL>
* "1" = ALLOW, "0" = DENY. A currently-connected tunnel is dropped within ~1s
* when "0" is written; "1" re-allows it. No-op (returns false) when REDIS_URL
* is unset — behavior then falls back to the gate's Postgres `is_active` read,
* exactly as before this wiring existed. Never throws.
*/
export async function setTunnelActive(
subdomain: string,
active: boolean,
): Promise<boolean> {
if (!process.env.REDIS_URL) return false;
if (!subdomain) return false;
try {
return await redisSet(
`tunnel:active:${subdomain}`,
active ? '1' : '0',
tunnelActiveTtl(),
);
} catch {
return false;
}
}