initial commit on Federted servers
release gate / check (push) Failing after 8m35s

This commit is contained in:
Andrew S Erwin
2026-10-19 16:23:55 -05:00
commit b9721ff13f
217 changed files with 31534 additions and 0 deletions
+59
View File
@@ -0,0 +1,59 @@
import 'server-only'
import jwt from 'jsonwebtoken'
import { createHmac } from 'node:crypto'
import { auth } from '@/auth'
import { cookies } from 'next/headers'
export const apiBase = () => process.env.API_BASE_URL || 'http://localhost:8080'
// Review SEC-2: the browser IP is forwarded to Go only when an operator has
// pinned the trusted proxy boundary (TRUSTED_PROXY=1) that overwrites
// X-Real-IP — the shipped nginx template does. X-Forwarded-For is never
// used: its leftmost entry is client-supplied. The HMAC covers ip|unix_ts
// and Go rejects timestamps older than ~60s, bounding header replay.
export const observedClientIP = (headers: Headers): string | null => {
if (process.env.TRUSTED_PROXY !== '1') return null
const ip = headers.get('x-real-ip')
return ip && ip.trim() ? ip.trim() : null
}
export const signClientIP = (ip: string): { ts: string; sig: string } => {
const secret = process.env.NEXTAUTH_SECRET || process.env.AUTH_SECRET
if (!secret) throw new Error('Missing authentication secret')
const ts = String(Math.floor(Date.now() / 1000))
return { ts, sig: createHmac('sha256', secret).update(`${ip}|${ts}`).digest('hex') }
}
// Only this trusted Next server signs assertions. The browser never chooses a
// user ID, and Go still re-reads roles/ownership on every request. The typ
// claim binds the token to the Bearer channel; Go rejects it as a cookie and
// rejects long-lived session tokens presented as assertions. The ver claim
// carries the login-time tokenVersion (review SEC-02): Go rejects assertions
// whose version predates a revocation bump, so "sign out everywhere" also
// kills NextAuth-backed browser sessions on their next backend call. Sessions
// predating ver are rejected and must sign in again.
export async function backendFetch(path: string, init: RequestInit = {}) {
const session = await auth()
const headers = new Headers(init.headers)
const secret = process.env.NEXTAUTH_SECRET || process.env.AUTH_SECRET
if (session?.user?.id) {
if (!secret) throw new Error('Missing authentication secret')
headers.set('Authorization', `Bearer ${jwt.sign({ uid: session.user.id, ver: session.user.ver, typ: 'assertion' }, secret, {
algorithm: 'HS256', issuer: 'computer', expiresIn: '60s',
})}`)
}
// Review AUTH-RATE/SEC-2: attest the browser IP for Go's login/signup
// throttling so proxied users do not all share this server's peer-IP
// bucket. The timestamped HMAC keeps the header unforgeable and
// short-lived; Go falls back to the direct peer when it is missing or
// invalid.
const clientIP = headers.get('x-computer-client-ip')
if (clientIP && secret) {
const { ts, sig } = signClientIP(clientIP)
headers.set('x-computer-client-ts', ts)
headers.set('x-computer-client-sig', sig)
}
const viewAs = (await cookies()).get('computer_view_as')?.value
if (viewAs) headers.set('Cookie', `computer_view_as=${encodeURIComponent(viewAs)}`)
return fetch(`${apiBase()}${path}`, { ...init, headers, cache: 'no-store' })
}
+6
View File
@@ -0,0 +1,6 @@
import { backendFetch } from '@/lib/api'
export const VIEW_AS_COOKIE = 'computer_view_as'
export type AuthUser = { id: string; name?: string | null; email?: string | null; image?: string | null; role: 'USER' | 'ADMIN'; actingAdminId?: string }
async function account() { const res = await backendFetch('/api/me'); return res.ok ? res.json() : null }
export async function getRealUser(): Promise<AuthUser | null> { return (await account())?.user ?? null }
export async function getAuthUser(): Promise<AuthUser | null> { const me = await account(); return me ? { ...me.effectiveUser, ...(me.viewingAs ? { actingAdminId: me.user.id } : {}) } : null }
+19
View File
@@ -0,0 +1,19 @@
import type { Bookmark } from '@/components/app-shell'
// The API keeps its array response; follow its cursor until the collection is complete.
export async function fetchBookmarks(query = new URLSearchParams(), signal?: AbortSignal): Promise<Bookmark[]> {
const params = new URLSearchParams(query)
const bookmarks = new Map<string, Bookmark>()
const seen = new Set<string>()
for (;;) {
const response = await fetch(`/api/bookmarks?${params}`, { signal })
if (!response.ok) throw new Error('Could not load bookmarks')
const page: Bookmark[] = await response.json()
for (const bookmark of page) bookmarks.set(bookmark.id, bookmark)
const cursor = response.headers.get('X-Next-Cursor')
if (!cursor) return [...bookmarks.values()]
if (seen.has(cursor)) throw new Error('Invalid bookmark pagination')
seen.add(cursor)
params.set('cursor', cursor)
}
}
+83
View File
@@ -0,0 +1,83 @@
import { useEffect, useState } from 'react'
// Shared client-side layer over `GET /api/preview?url=`. The Go endpoint
// dials the target site (netguard-protected, 8s bound), so results are
// memoized per URL and concurrent requests are deduplicated. This is what
// makes inline link cards in Markdown pages cheap to render.
export type LinkPreviewData = {
title: string | null
description: string | null
favicon: string | null
ogImage: string | null
ogTitle: string | null
ogDescription: string | null
}
type Entry = { value: LinkPreviewData | null; at: number }
const cache = new Map<string, Entry>()
const inflight = new Map<string, Promise<LinkPreviewData | null>>()
// Failures (offline target, blocked host, endpoint down) are transient, so
// negative results expire quickly instead of sticking for the session.
const NEGATIVE_TTL_MS = 60_000
export function cachedLinkPreview(url: string): LinkPreviewData | null | undefined {
const hit = cache.get(url)
if (!hit) return undefined
if (hit.value === null && Date.now() - hit.at > NEGATIVE_TTL_MS) {
cache.delete(url)
return undefined
}
return hit.value
}
export async function fetchLinkPreview(url: string): Promise<LinkPreviewData | null> {
const hit = cachedLinkPreview(url)
if (hit !== undefined) return hit
const pending = inflight.get(url)
if (pending) return pending
const run = (async () => {
try {
const res = await fetch(`/api/preview?url=${encodeURIComponent(url)}`)
if (!res.ok) return null
const data = await res.json()
if (!data || typeof data !== 'object') return null
const str = (v: unknown) => (typeof v === 'string' && v.trim() ? v : null)
const value: LinkPreviewData = {
title: str(data.title),
description: str(data.description),
favicon: str(data.favicon),
ogImage: str(data.ogImage),
ogTitle: str(data.ogTitle),
ogDescription: str(data.ogDescription),
}
// An object with nothing displayable counts as a miss.
return value.title || value.description || value.ogImage ? value : null
} catch {
return null
} finally {
inflight.delete(url)
}
})()
inflight.set(url, run)
const value = await run
cache.set(url, { value, at: Date.now() })
return value
}
// undefined = still loading, null = nothing resolved. The value is derived
// from the module cache during render (identical on server and client first
// paint — no hydration mismatch); the effect only kicks off the fetch and
// requests a re-render once the cache is populated.
export function useLinkPreview(url: string | null): LinkPreviewData | null | undefined {
const [, bump] = useState(0)
useEffect(() => {
if (!url || cachedLinkPreview(url) !== undefined) return
let active = true
void fetchLinkPreview(url).then(() => { if (active) bump((n) => n + 1) })
return () => { active = false }
}, [url])
return url ? cachedLinkPreview(url) : undefined
}
+42
View File
@@ -0,0 +1,42 @@
/**
* Authelia OIDC configuration (STUB-AWARE).
*
* Authelia sign-in is only enabled when all three env vars hold real values:
* OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET
*
* Empty values or obvious placeholders (REPLACE_ME, example.com, changeme, ...)
* are treated as "not connected": the Authelia provider is not registered,
* the login button is shown disabled, and email/password login keeps working.
*
* See docs/AUTHELIA.md for the full connection guide.
*/
const PLACEHOLDER_PATTERNS = [/replace[_-]?me/i, /example\.(com|org|net)/i, /changeme/i, /placeholder/i, /^<.*>$/, /^your[_-]/i, /^todo$/i]
function isRealValue(value: string | undefined): value is string {
const v = (value ?? '').trim()
if (!v) return false
return !PLACEHOLDER_PATTERNS.some((p) => p.test(v))
}
export interface OidcStatus {
enabled: boolean
issuerSet: boolean
clientIdSet: boolean
clientSecretSet: boolean
/** Only exposed when the issuer is a real value (never exposes secrets). */
issuer: string | null
}
export function getOidcStatus(): OidcStatus {
const issuerSet = isRealValue(process.env.OIDC_ISSUER)
const clientIdSet = isRealValue(process.env.OIDC_CLIENT_ID)
const clientSecretSet = isRealValue(process.env.OIDC_CLIENT_SECRET)
return {
enabled: issuerSet && clientIdSet && clientSecretSet,
issuerSet,
clientIdSet,
clientSecretSet,
issuer: issuerSet ? process.env.OIDC_ISSUER!.trim() : null,
}
}
+215
View File
@@ -0,0 +1,215 @@
export type PageSaveState = 'saved' | 'saving' | 'dirty' | 'conflict'
type Draft = {
content: string
revision: number
savedRevision: number
// The server version this content was edited from (review DATA-03). It is
// persisted with the draft so a recovery after reload cannot silently
// re-base onto a newer remote version.
baseVersion: number | undefined
// Server version reported by a 409 or detected during recovery, while
// autosave is paused (review DATA-04). Typing never resolves a conflict.
conflict: number | null
timer: ReturnType<typeof setTimeout> | null
saving: Promise<boolean> | null
}
// Storage envelope: content plus the server version it was based on. Plain
// string drafts written by older builds are still recovered, but their
// unknown base pauses recovery for review (DATA-06) — never a silent rebase.
type Envelope = { content: string; baseVersion?: number }
// Thrown by the save callback when the server rejects a stale base version
// (optimistic concurrency, review DATA-01). serverVersion is the revision the
// server reported; the queue enters a paused conflict state instead of
// adopting it — overwriting requires an explicit user decision (DATA-04).
export class PageConflictError extends Error {
constructor(readonly serverVersion?: number) { super('Page was modified elsewhere') }
}
// Each page owns its debounce and serial save queue. A late response cannot
// overwrite a newer edit, and tab-local drafts survive a failed save/navigation.
export class PageAutosave {
private drafts = new Map<string, Draft>()
// Server revision per page: sent with content saves and refreshed from save
// responses, so concurrent tabs/devices get 409s instead of silent overwrites.
private versions = new Map<string, number>()
constructor(
// Resolves with the server's new version when the response carries one.
private save: (id: string, content: string, version: number | undefined) => Promise<number | void>,
private state: (id: string, state: PageSaveState) => void,
) {}
setVersion(id: string, version: number) { this.versions.set(id, version) }
private key(id: string) { return `computer:page-draft:${id}` }
private store(id: string, draft: Draft | null) {
try {
if (draft === null) sessionStorage.removeItem(this.key(id))
else {
const envelope: Envelope = { content: draft.content }
if (draft.baseVersion !== undefined) envelope.baseVersion = draft.baseVersion
sessionStorage.setItem(this.key(id), JSON.stringify(envelope))
}
} catch { /* Saving to the API still works if browser storage is unavailable. */ }
}
// restore recovers an unsaved draft for id, comparing the version it was
// based on with the freshly fetched server version (review DATA-03). A
// mismatch means another session saved in between: the draft enters the
// paused conflict state instead of being rebased onto the newer version.
restore(id: string, serverVersion?: number): string | undefined {
let draft = this.drafts.get(id)
if (draft && draft.revision <= draft.savedRevision) draft = undefined
if (!draft) {
try {
const raw = sessionStorage.getItem(this.key(id))
if (raw !== null) {
const parsed: unknown = JSON.parse(raw)
let content: string | undefined
let baseVersion: number | undefined
if (typeof parsed === 'string') content = parsed // legacy plain-string draft
else if (parsed && typeof parsed === 'object') {
const envelope = parsed as Envelope
if (typeof envelope.content === 'string') content = envelope.content
if (typeof envelope.baseVersion === 'number') baseVersion = envelope.baseVersion
}
if (content !== undefined) {
draft = { content, revision: 1, savedRevision: 0, baseVersion, conflict: null, timer: null, saving: null }
this.drafts.set(id, draft)
}
}
} catch { /* Ignore unavailable storage or malformed drafts. */ }
}
if (!draft) return undefined
// A known stale base (DATA-03) — or an unknown one (review DATA-06):
// legacy plain-string drafts and envelopes stored before base tracking
// cannot prove they predate the remote content, so recovery pauses them
// for explicit review (compare via discard-to-load, copy, overwrite)
// instead of letting the next keystroke save over a newer version.
if (draft.conflict === null && typeof serverVersion === 'number' &&
(draft.baseVersion === undefined || draft.baseVersion !== serverVersion)) {
draft.conflict = serverVersion
}
this.state(id, draft.conflict !== null ? 'conflict' : 'dirty')
return draft.content
}
edit(id: string, content: string) {
const draft = this.drafts.get(id) ?? {
content, revision: 0, savedRevision: 0,
baseVersion: this.versions.get(id), conflict: null, timer: null, saving: null,
}
draft.content = content
draft.revision++
if (draft.timer) { clearTimeout(draft.timer); draft.timer = null }
this.drafts.set(id, draft)
this.store(id, draft)
// Review DATA-04: while a conflict is unresolved, typing keeps the local
// draft but never schedules a save — keystrokes are not consent to
// overwrite another session's work.
if (draft.conflict !== null) { this.state(id, 'conflict'); return }
this.state(id, 'dirty')
draft.timer = setTimeout(() => { void this.flush(id) }, 800)
}
async flush(id: string): Promise<boolean> {
const draft = this.drafts.get(id)
if (!draft) return true
if (draft.timer) { clearTimeout(draft.timer); draft.timer = null }
if (draft.saving) return draft.saving
if (draft.conflict !== null) return false
if (draft.revision === draft.savedRevision) return true
draft.saving = this.persist(id, draft)
try { return await draft.saving }
finally { draft.saving = null }
}
private async persist(id: string, draft: Draft): Promise<boolean> {
while (draft.savedRevision < draft.revision) {
const revision = draft.revision
const content = draft.content
this.state(id, 'saving')
try {
// Review CR-17: the expected revision stays bound to the content
// snapshot this draft was actually edited from (its baseVersion).
// The shared version map can move via other responses; consulting it
// here would let a metadata mutation silently rebase a stale draft
// onto a newer remote save. The base advances only from an
// acknowledged save of this content (below) or an explicit
// conflict resolution (resolveOverwrite).
const base = draft.baseVersion ?? this.versions.get(id)
const version = await this.save(id, content, base)
if (typeof version === 'number') {
this.versions.set(id, version)
draft.baseVersion = version
}
} catch (error) {
if (error instanceof PageConflictError) {
// Pause on the reported remote version without adopting it as the
// save base; resolveOverwrite is the only path that rebases.
draft.conflict = typeof error.serverVersion === 'number'
? error.serverVersion
: (this.versions.get(id) ?? 0)
this.state(id, 'conflict')
return false
}
this.state(id, 'dirty')
return false
}
draft.savedRevision = revision
// Review DATA-07: refresh the stored envelope after every successful
// iteration, not only from edit(). If a later save in this loop fails
// (or a reload interrupts it), recovery must see the newest content
// with the advanced base — a stale stored base would flag an already
// clean draft as a spurious conflict.
this.store(id, draft)
}
if (draft.timer) { clearTimeout(draft.timer); draft.timer = null }
this.store(id, null)
this.state(id, 'saved')
return true
}
// conflictVersion reports the remote version a paused conflict was detected
// against, or null when the page is not in the conflict state.
conflictVersion(id: string): number | null {
return this.drafts.get(id)?.conflict ?? null
}
// resolveOverwrite is the explicit user decision to replace the newer
// remote content with the local draft (review DATA-04).
async resolveOverwrite(id: string): Promise<boolean> {
const draft = this.drafts.get(id)
if (!draft || draft.conflict === null) return true
this.versions.set(id, draft.conflict)
draft.baseVersion = draft.conflict
draft.conflict = null
// Review DATA-07: persist the rebased envelope immediately so a failed
// overwrite save still recovers against the version it chose to replace.
this.store(id, draft)
this.state(id, 'dirty')
return this.flush(id)
}
// discard drops the local draft after an explicit user decision; the caller
// reloads the page so the editor shows the remote content again.
discard(id: string) {
const draft = this.drafts.get(id)
if (draft?.timer) clearTimeout(draft.timer)
this.drafts.delete(id)
this.store(id, null)
this.state(id, 'saved')
}
hasUnsaved() { return [...this.drafts.values()].some(draft => draft.revision > draft.savedRevision) }
async flushAll() { return (await Promise.all([...this.drafts.keys()].map(id => this.flush(id)))).every(Boolean) }
forget(id: string) {
const draft = this.drafts.get(id)
if (draft?.timer) clearTimeout(draft.timer)
this.drafts.delete(id)
this.versions.delete(id)
this.store(id, null)
}
}
+25
View File
@@ -0,0 +1,25 @@
import type { PageSummary } from '@/lib/pages'
/**
* Reorder pages by moving sourceId beside targetId. Pinned and unpinned pages
* are separate groups (the server sorts pinned first), so a cross-group drop
* returns 'blocked' instead of silently landing at a different boundary.
* `after` places the source after the target; otherwise it goes before it.
*/
export function reorderPages(
pages: PageSummary[],
sourceId: string,
targetId: string,
after = false,
): PageSummary[] | 'blocked' | null {
const from = pages.findIndex((p) => p.id === sourceId)
const to = pages.findIndex((p) => p.id === targetId)
if (from < 0 || to < 0 || sourceId === targetId) return null
if (pages[from].pinned !== pages[to].pinned) return 'blocked'
const next = [...pages]
const [moved] = next.splice(from, 1)
let insertAt = to + (after ? 1 : 0)
if (from < insertAt) insertAt--
next.splice(insertAt, 0, moved)
return next
}
+25
View File
@@ -0,0 +1,25 @@
export const PAGES_CHANGED = 'computer:pages-changed'
export const notifyPagesChanged = () => window.dispatchEvent(new Event(PAGES_CHANGED))
// Notion-style breadcrumbs: the pages workspace announces which page is open
// so the top bar can show "computer / Pages / <title>" without refetching.
export const ACTIVE_PAGE_CHANGED = 'computer:active-page-changed'
export const notifyActivePage = (title: string | null) =>
window.dispatchEvent(new CustomEvent<string | null>(ACTIVE_PAGE_CHANGED, { detail: title }))
export type PageSummary = { id: string; title: string; pinned: boolean; position: number; _count?: { bookmarks: number } }
// Pages start as "Untitled 1", "Untitled 2", ... until the user renames them.
// Pick the lowest positive integer not already used by an untitled page.
export function nextUntitledTitle(titles: string[]): string {
const used = new Set<number>()
for (const t of titles) {
const m = /^Untitled (\d+)$/.exec(t.trim())
if (m) used.add(parseInt(m[1], 10))
}
let n = 1
while (used.has(n)) n++
return `Untitled ${n}`
}
+38
View File
@@ -0,0 +1,38 @@
// Short-TTL cache with in-flight deduplication for the reference data the
// bookmark detail sheet needs on every open (identity, kanban columns,
// contacts, tags). Mutation sites call invalidateReference() so a just-created
// tag/column/contact shows up immediately instead of after the TTL.
export type ReferenceKind = 'me' | 'columns' | 'contacts' | 'tags'
const PATHS: Record<ReferenceKind, string> = {
me: '/api/me',
columns: '/api/kanban/columns',
contacts: '/api/contacts',
tags: '/api/tags',
}
const TTL_MS = 30_000
const cache = new Map<ReferenceKind, { at: number; promise: Promise<unknown> }>()
export function invalidateReference(kind: ReferenceKind) {
cache.delete(kind)
}
// Resolves to null on any failure; failures are not kept cached.
export function fetchReference<T = unknown>(kind: ReferenceKind): Promise<T | null> {
const hit = cache.get(kind)
if (hit && Date.now() - hit.at < TTL_MS) return hit.promise as Promise<T | null>
const promise = (async () => {
try {
const res = await fetch(PATHS[kind])
return res.ok ? ((await res.json()) as T) : null
} catch {
return null
}
})()
const entry = { at: Date.now(), promise }
cache.set(kind, entry)
void promise.then((v) => { if (v === null && cache.get(kind) === entry) cache.delete(kind) })
return promise
}
+28
View File
@@ -0,0 +1,28 @@
export type Expense = {
id: string
amount: number
category: string
description: string
date: Date
}
export type ExpenseFormData = Omit<Expense, 'id' | 'date'> & {
date: string
}
export const EXPENSE_CATEGORIES = [
'Food',
'Transportation',
'Housing',
'Utilities',
'Entertainment',
'Healthcare',
'Shopping',
'Education',
'Other'
] as const
export type DateRange = {
from: Date | undefined
to: Date | undefined
}
+14
View File
@@ -0,0 +1,14 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const remainingSeconds = seconds % 60
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`
}