Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
f7e4ef5
fix: preserve parser and rule configuration contracts
Ryson-32 Jul 13, 2026
b485967
fix: strengthen local runtime safeguards
Ryson-32 Jul 13, 2026
58f14a0
refactor: clarify public type contracts
Ryson-32 Jul 13, 2026
e5e36e2
test: split oversized public contract suites
Ryson-32 Jul 13, 2026
ef47e39
fix: keep internal legacy migrations private
Ryson-32 Jul 13, 2026
7857ad9
test: detach self-host shell probes from terminals
Ryson-32 Jul 13, 2026
a615bdc
docs: clarify subscription bearer responsibility
Ryson-32 Jul 13, 2026
d82e2b4
docs: surface subscription owner warning
Ryson-32 Jul 13, 2026
73f9f72
fix(copy): keep subscription warnings concise
Ryson-32 Jul 13, 2026
a0f6c83
feat(local): add high-risk source import toggle
Ryson-32 Jul 13, 2026
12425da
fix(local): keep imports root-typecheck compatible
Ryson-32 Jul 13, 2026
295f96d
fix(local): preserve setup lock with prisma adapter
Ryson-32 Jul 13, 2026
89c652c
fix: stabilize subscription imports and validation
Ryson-32 Jul 15, 2026
a11e2c6
fix: harden local runtime boundaries
Ryson-32 Jul 16, 2026
91afc93
fix: make local updates recoverable
Ryson-32 Jul 16, 2026
0846a94
docs: document safe setup and update recovery
Ryson-32 Jul 16, 2026
0b8333a
fix: isolate installer registry credentials
Ryson-32 Jul 16, 2026
6bb0597
fix: protect self-hosted backups
Ryson-32 Jul 16, 2026
ed76554
test: preserve jsx runtime semantics
Ryson-32 Jul 16, 2026
46ca2d6
docs: keep the project overview concise
Ryson-32 Jul 16, 2026
f39b8f3
fix: label compact navigation controls
Ryson-32 Jul 16, 2026
d510212
test: assert expected component failure logs
Ryson-32 Jul 16, 2026
90fba5c
build: retry transient dependency downloads
Ryson-32 Jul 17, 2026
427fb43
build: cache verified dependency downloads
Ryson-32 Jul 17, 2026
9f299cb
fix: keep shell validation portable
Ryson-32 Jul 17, 2026
78c952a
feat(ui): add accessible form control primitives
Ryson-32 Jul 18, 2026
6b983d1
refactor(ui): unify public and local interactions
Ryson-32 Jul 18, 2026
a9d6437
fix(ui): align form field aria prop types
Ryson-32 Jul 18, 2026
dbc0eef
chore(ui): enforce icon button labels
Ryson-32 Jul 18, 2026
5c1ae39
fix(ui): restore source and advanced mode styling
Ryson-32 Jul 18, 2026
a08c7a3
fix(ui): restore full-row group disclosure
Ryson-32 Jul 18, 2026
afc28f2
fix(deps): resolve runtime security advisories
Ryson-32 Jul 26, 2026
f50dd5f
Merge pull request #63 from SubBoost/ryan/dependency-security
Ryson-32 Jul 26, 2026
889aa29
feat(core): add persistent node regex filtering
Ryson-32 Jul 26, 2026
e4ea0a5
feat(ui): add automatic node processing
Ryson-32 Jul 26, 2026
b842fb5
test(ui): cover filter import refresh paths
Ryson-32 Jul 26, 2026
1dd7a73
fix(security): keep regex validation linear
Ryson-32 Jul 26, 2026
b130229
Merge pull request #65 from SubBoost/ryan/dependency-security-codeql
Ryson-32 Jul 26, 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
20 changes: 19 additions & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,26 @@ import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTypescript from "eslint-config-next/typescript";

const accessibilityRules = {
"jsx-a11y/click-events-have-key-events": "error",
"jsx-a11y/label-has-associated-control": "error",
"jsx-a11y/no-static-element-interactions": "error",
};

const nextVitalsWithAccessibility = nextVitals.map((config) =>
config.name === "next"
? {
...config,
rules: {
...config.rules,
...accessibilityRules,
},
}
: config
);

export default defineConfig([
...nextVitals,
...nextVitalsWithAccessibility,
...nextTypescript,
{
linterOptions: {
Expand Down
41 changes: 28 additions & 13 deletions local/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,27 @@ FROM node:22-alpine AS deps
WORKDIR /package
RUN apk add --no-cache openssl

COPY package.json package-lock.json ./
COPY packages/core packages/core
COPY packages/server-core packages/server-core
COPY packages/ui packages/ui
COPY packages/config packages/config
COPY local/package*.json local/
COPY local/package.json local/package.json

WORKDIR /package/local
RUN npm install
RUN --mount=type=cache,target=/root/.npm,sharing=locked \
set -eu; \
attempt=1; \
max_attempts=3; \
until npm ci; do \
if [ "$attempt" -ge "$max_attempts" ]; then \
echo "npm ci failed after ${max_attempts} attempts" >&2; \
exit 1; \
fi; \
delay=$((attempt * 10)); \
echo "npm ci attempt ${attempt} failed; retrying in ${delay}s" >&2; \
sleep "$delay"; \
attempt=$((attempt + 1)); \
done

FROM node:22-alpine AS builder
WORKDIR /package
Expand All @@ -24,8 +37,7 @@ ENV APP_BUILD_SHA=${APP_BUILD_SHA}
ENV APP_VERSION=${APP_VERSION}
ENV APP_VERSION_TOKEN=${APP_VERSION_TOKEN}

COPY --from=deps /package/local/node_modules local/node_modules
RUN ln -s local/node_modules node_modules
COPY --from=deps /package/node_modules node_modules
COPY packages/core packages/core
COPY packages/server-core packages/server-core
COPY packages/ui packages/ui
Expand All @@ -35,6 +47,9 @@ COPY local local
WORKDIR /package/local
RUN npm run build

FROM deps AS production-deps
RUN npm prune --omit=dev

FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
Expand All @@ -48,14 +63,14 @@ ENV APP_VERSION=${APP_VERSION}
ENV APP_VERSION_TOKEN=${APP_VERSION_TOKEN}
RUN apk add --no-cache openssl

COPY --from=builder /package/local/.next/standalone ./
COPY --from=deps /package/local/node_modules local/node_modules
COPY --from=builder /package/local/.next/static local/.next/static
COPY --from=builder /package/local/public local/public
COPY --from=builder /package/local/prisma.config.ts local/prisma.config.ts
COPY --from=builder /package/local/prisma local/prisma
RUN if [ ! -e node_modules ] && [ -d local/node_modules ]; then ln -s local/node_modules node_modules; fi
COPY --chown=node:node --from=builder /package/local/.next/standalone ./
COPY --chown=node:node --from=production-deps /package/node_modules ./node_modules
COPY --chown=node:node --from=builder /package/local/.next/static local/.next/static
COPY --chown=node:node --from=builder /package/local/public local/public
COPY --chown=node:node --from=builder /package/local/prisma.config.ts local/prisma.config.ts
COPY --chown=node:node --from=builder /package/local/prisma local/prisma

WORKDIR /app/local
USER node
EXPOSE 3000
CMD ["sh", "-c", "./node_modules/.bin/prisma migrate deploy --schema prisma/schema.prisma && node server.js"]
CMD ["sh", "-c", "../node_modules/.bin/prisma migrate deploy --schema prisma/schema.prisma && node server.js"]
11 changes: 11 additions & 0 deletions local/app/api/auth/local-auth-routes.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { clearLocalRateLimitsForTests } from "@local/lib/rate-limit";

const mocks = vi.hoisted(() => ({
bcryptCompare: vi.fn(),
Expand Down Expand Up @@ -49,6 +50,7 @@ async function readJson(response: Response) {
describe("local auth and health routes", () => {
beforeEach(() => {
vi.clearAllMocks();
clearLocalRateLimitsForTests();
});

it("logs in a valid local admin and sets the session cookie", async () => {
Expand Down Expand Up @@ -101,6 +103,15 @@ describe("local auth and health routes", () => {
status: 401,
body: { error: "Invalid username or password.", code: "UNAUTHORIZED" },
});

await expect(readJson(await POST(new Request("https://local.test/api/auth/login", {
method: "POST",
headers: { "content-length": String(64 * 1024 + 1) },
body: "{}",
})))).resolves.toEqual({
status: 413,
body: { error: "Request body is too large.", code: "PAYLOAD_TOO_LARGE" },
});
});

it("logs out by clearing the session cookie", async () => {
Expand Down
37 changes: 34 additions & 3 deletions local/app/api/auth/login/route.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,44 @@
import bcrypt from "bcryptjs";
import { NextResponse } from "next/server";
import { apiError, getStringField, readJsonBody } from "@local/lib/http";
import { apiError, getStringField, jsonBodyError, LOCAL_JSON_BODY_LIMITS, readJsonBody } from "@local/lib/http";
import { prisma } from "@local/lib/prisma";
import { sessionCookieOptions, signSession, SESSION_COOKIE } from "@local/lib/session";
import {
consumeLocalRateLimit,
getTrustedClientRateLimitKey,
hashLocalRateLimitKey,
localRateLimitResponse,
resetLocalRateLimit,
} from "@local/lib/rate-limit";

const LOGIN_WINDOW_MS = 15 * 60 * 1000;

export async function POST(request: Request) {
const body = await readJsonBody(request);
if (!body) return apiError("Invalid JSON body.", "BAD_REQUEST", 400);
const clientKey = getTrustedClientRateLimitKey(request);
if (clientKey) {
const clientLimit = consumeLocalRateLimit("auth-login-client", clientKey, {
limit: 30,
windowMs: LOGIN_WINDOW_MS,
});
if (!clientLimit.allowed) {
return localRateLimitResponse("Too many login attempts. Try again later.", clientLimit.retryAfterSeconds);
}
}

const parsedBody = await readJsonBody(request, LOCAL_JSON_BODY_LIMITS.small);
if (!parsedBody.ok) return jsonBodyError(parsedBody);
const body = parsedBody.value;

const username = getStringField(body, "username");
const password = getStringField(body, "password");
const usernameLimitKey = hashLocalRateLimitKey(username.toLowerCase() || "missing");
const usernameLimit = consumeLocalRateLimit("auth-login-username", usernameLimitKey, {
limit: 8,
windowMs: LOGIN_WINDOW_MS,
});
if (!usernameLimit.allowed) {
return localRateLimitResponse("Too many login attempts. Try again later.", usernameLimit.retryAfterSeconds);
}
const admin = username
? await prisma.localAdmin.findUnique({ where: { username }, select: { id: true, username: true, passwordHash: true } })
: null;
Expand All @@ -18,6 +47,8 @@ export async function POST(request: Request) {
return apiError("Invalid username or password.", "UNAUTHORIZED", 401);
}

resetLocalRateLimit("auth-login-username", usernameLimitKey);

await prisma.localAdmin.update({ where: { id: admin.id }, data: { lastLoginAt: new Date() } });
const response = NextResponse.json({ success: true, user: { id: admin.id, username: admin.username } });
response.cookies.set(SESSION_COOKIE, await signSession({ adminId: admin.id, username: admin.username }), sessionCookieOptions());
Expand Down
41 changes: 35 additions & 6 deletions local/app/api/cron/update-subscriptions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,44 @@ import { NextRequest } from "next/server";
import { json } from "@local/lib/http";
import { requireLocalCronAuth } from "@local/lib/cron-auth";
import { runLocalSubscriptionAutoUpdateCron } from "@local/lib/auto-update-service";
import {
acquireLocalJobLease,
JobLeaseLostError,
releaseLocalJobLease,
startLocalJobLeaseHeartbeat,
type LocalJobLease,
} from "@local/lib/job-lease";

const LEASE_NAME = "local-subscription-auto-update";
const LEASE_MS = 5 * 60 * 1000;
const HEARTBEAT_MS = 60 * 1000;

export async function POST(request: NextRequest) {
const authError = requireLocalCronAuth(request);
if (authError) return authError;

const summary = await runLocalSubscriptionAutoUpdateCron();
return json({
success: true,
...summary,
timestamp: new Date().toISOString(),
});
let lease: LocalJobLease | null = null;
let heartbeat: ReturnType<typeof startLocalJobLeaseHeartbeat> | null = null;
try {
lease = await acquireLocalJobLease({ name: LEASE_NAME, leaseMs: LEASE_MS });
if (!lease) return json({ success: true, skipped: true, reason: "already_running" });

heartbeat = startLocalJobLeaseHeartbeat({ lease, leaseMs: LEASE_MS, intervalMs: HEARTBEAT_MS });
const summary = await runLocalSubscriptionAutoUpdateCron(new Date(), {
assertLease: heartbeat.assertOwned,
});
return json({
success: true,
...summary,
timestamp: new Date().toISOString(),
});
} catch (error) {
if (error instanceof JobLeaseLostError) {
return json({ error: "Local subscription update lease lost.", code: "JOB_LEASE_LOST" }, 503);
}
throw error;
} finally {
await heartbeat?.stop();
if (lease) await releaseLocalJobLease(lease).catch(() => undefined);
}
}
117 changes: 117 additions & 0 deletions local/app/api/settings/source-import/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
getCurrentAdmin: vi.fn(),
prisma: {
localAdmin: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));

vi.mock("@local/lib/auth", () => ({
getCurrentAdmin: mocks.getCurrentAdmin,
}));

vi.mock("@local/lib/prisma", () => ({
prisma: mocks.prisma,
}));

import { GET, PATCH } from "./route";

async function readJson(response: Response) {
return { status: response.status, body: await response.json() };
}

function patchRequest(body: string) {
return new Request("https://local.test/api/settings/source-import", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body,
});
}

describe("local source import settings route", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getCurrentAdmin.mockResolvedValue({ id: "admin-1", username: "admin" });
});

it("requires the local administrator for reads and writes", async () => {
mocks.getCurrentAdmin.mockResolvedValue(null);

await expect(readJson(await GET())).resolves.toEqual({
status: 401,
body: { error: "Authentication required.", code: "UNAUTHORIZED" },
});
await expect(readJson(await PATCH(patchRequest("{}")))).resolves.toEqual({
status: 401,
body: { error: "Authentication required.", code: "UNAUTHORIZED" },
});
expect(mocks.prisma.localAdmin.findUnique).not.toHaveBeenCalled();
expect(mocks.prisma.localAdmin.update).not.toHaveBeenCalled();
});

it("returns the persisted setting", async () => {
mocks.prisma.localAdmin.findUnique.mockResolvedValueOnce({
allowUnsafeSubscriptionSources: true,
});

await expect(readJson(await GET())).resolves.toEqual({
status: 200,
body: { allowUnsafeSubscriptionSources: true },
});
expect(mocks.prisma.localAdmin.findUnique).toHaveBeenCalledWith({
where: { id: "admin-1" },
select: { allowUnsafeSubscriptionSources: true },
});
});

it("updates the setting with a boolean value", async () => {
mocks.prisma.localAdmin.update.mockResolvedValueOnce({
allowUnsafeSubscriptionSources: true,
});

await expect(
readJson(await PATCH(patchRequest(JSON.stringify({ allowUnsafeSubscriptionSources: true }))))
).resolves.toEqual({
status: 200,
body: { allowUnsafeSubscriptionSources: true },
});
expect(mocks.prisma.localAdmin.update).toHaveBeenCalledWith({
where: { id: "admin-1" },
data: { allowUnsafeSubscriptionSources: true },
select: { allowUnsafeSubscriptionSources: true },
});
});

it("rejects malformed JSON and non-boolean values", async () => {
await expect(readJson(await PATCH(patchRequest("{")))).resolves.toEqual({
status: 400,
body: { error: "Invalid JSON body.", code: "BAD_REQUEST" },
});
await expect(
readJson(await PATCH(patchRequest(JSON.stringify({ allowUnsafeSubscriptionSources: "yes" }))))
).resolves.toEqual({
status: 400,
body: {
error: "allowUnsafeSubscriptionSources must be a boolean.",
code: "VALIDATION_ERROR",
},
});
expect(mocks.prisma.localAdmin.update).not.toHaveBeenCalled();
});

it("rejects settings bodies above 64 KiB", async () => {
const response = await PATCH(new Request("https://local.test/api/settings/source-import", {
method: "PATCH",
headers: { "content-length": String(64 * 1024 + 1) },
body: "{}",
}));
expect(await readJson(response)).toEqual({
status: 413,
body: { error: "Request body is too large.", code: "PAYLOAD_TOO_LARGE" },
});
});
});
39 changes: 39 additions & 0 deletions local/app/api/settings/source-import/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { withCurrentAdmin } from "@local/lib/api-auth";
import { apiError, json, jsonBodyError, LOCAL_JSON_BODY_LIMITS, readJsonBody } from "@local/lib/http";
import { prisma } from "@local/lib/prisma";

export async function GET() {
return withCurrentAdmin(async (admin) => {
const settings = await prisma.localAdmin.findUnique({
where: { id: admin.id },
select: { allowUnsafeSubscriptionSources: true },
});

if (!settings) return apiError("Local admin not found.", "NOT_FOUND", 404);
return json(settings);
});
}

export async function PATCH(request: Request) {
return withCurrentAdmin(async (admin) => {
const parsedBody = await readJsonBody(request, LOCAL_JSON_BODY_LIMITS.small);
if (!parsedBody.ok) return jsonBodyError(parsedBody);
const body = parsedBody.value;

const allowUnsafeSubscriptionSources =
typeof body === "object" && !Array.isArray(body)
? (body as Record<string, unknown>).allowUnsafeSubscriptionSources
: undefined;
if (typeof allowUnsafeSubscriptionSources !== "boolean") {
return apiError("allowUnsafeSubscriptionSources must be a boolean.", "VALIDATION_ERROR", 400);
}

const settings = await prisma.localAdmin.update({
where: { id: admin.id },
data: { allowUnsafeSubscriptionSources },
select: { allowUnsafeSubscriptionSources: true },
});

return json(settings);
});
}
Loading
Loading