From 30194463b5c59a71dc2a47f72008f74218d35cf3 Mon Sep 17 00:00:00 2001 From: John Knapp Date: Fri, 12 Jun 2026 11:38:13 -0700 Subject: [PATCH 1/5] fix: bind memory.list tag filter as a single text[] param (#1) memory.list built its tag filter with a raw sql template: sql`${memories.tags} @> ${tags}::text[]` Drizzle expands a JS array embedded in a sql template into positional params, so one tag produced `@> ($1)::text[]` (Postgres rejected the bound string as a malformed array literal) and two tags produced `@> ($1,$2)::text[]` (a record, hence "cannot cast type record to text[]"). Switch to arrayContains(memories.tags, tags), which binds the array as one text[] param via the column's toDriver and preserves the "require ALL tags" (@>) semantics. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/lib/mcp/tools.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/web/lib/mcp/tools.ts b/apps/web/lib/mcp/tools.ts index 0ec4e00..6e4e8ac 100644 --- a/apps/web/lib/mcp/tools.ts +++ b/apps/web/lib/mcp/tools.ts @@ -1,4 +1,4 @@ -import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm"; +import { and, arrayContains, desc, eq, inArray, isNull, or } from "drizzle-orm"; import { db } from "@/lib/db/client"; import { memories, @@ -465,7 +465,13 @@ const memoryList: ToolDef = { } if (parsed.data.tags && parsed.data.tags.length > 0) { - where.push(sql`${memories.tags} @> ${parsed.data.tags}::text[]`); + // Require ALL listed tags (array containment). Use Drizzle's + // arrayContains so the JS array binds as a single text[] param + // (via the column's toDriver) rather than being expanded into + // positional params — a raw `${tags}::text[]` template expands to + // `($1)::text[]` / `($1,$2)::text[]`, which Postgres rejects as a + // malformed array literal / record cast. + where.push(arrayContains(memories.tags, parsed.data.tags)); } const rows = await db From 86433afe1fcc1531a828df1c54b24079eafd0157 Mon Sep 17 00:00:00 2001 From: John Knapp Date: Fri, 12 Jun 2026 11:38:22 -0700 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20list=20shared=20projects=20(owned=20?= =?UTF-8?q?=E2=88=AA=20shared)=20in=20Web=20UI=20project=20list=20(#2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Projects page filtered with eq(projects.userId, userId), so a user with an rw (or ro) share on someone else's project never saw it in the list — even though project.identify already returned {shared, access} for the same project. Switch to getAccessibleProjects(userId, groupNames) (owner ∪ group-shared, the same helper search/memories use) and aggregate counts over that id set, and label non-owned rows with a 'shared · ro|rw' badge. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/app/(authed)/projects/page.tsx | 53 ++++++++++++++++--------- 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/apps/web/app/(authed)/projects/page.tsx b/apps/web/app/(authed)/projects/page.tsx index 2a6f1c1..7ebd91b 100644 --- a/apps/web/app/(authed)/projects/page.tsx +++ b/apps/web/app/(authed)/projects/page.tsx @@ -1,8 +1,9 @@ import Link from "next/link"; -import { and, desc, eq, isNull, sql } from "drizzle-orm"; +import { and, desc, eq, inArray, isNull, sql } from "drizzle-orm"; import { auth } from "@/auth"; import { db } from "@/lib/db/client"; import { memories, projects } from "@/lib/db/schema"; +import { getAccessibleProjects, getUserGroupNames } from "@/lib/access"; import { Container, PageHeader } from "@/app/_components/ui/container"; import { Card } from "@/app/_components/ui/card"; import { Badge } from "@/app/_components/ui/badge"; @@ -13,24 +14,37 @@ export const dynamic = "force-dynamic"; export default async function ProjectsPage() { const session = await auth(); const userId = session!.user.id; + const groupNames = await getUserGroupNames(userId); - const rows = await db - .select({ - id: projects.id, - key: projects.key, - displayName: projects.displayName, - createdAt: projects.createdAt, - memoryCount: sql`count(${memories.id})::int`, - lastActivity: sql`max(${memories.createdAt})`, - }) - .from(projects) - .leftJoin( - memories, - and(eq(memories.projectId, projects.id), isNull(memories.deletedAt)), - ) - .where(eq(projects.userId, userId)) - .groupBy(projects.id) - .orderBy(desc(sql`max(${memories.createdAt})`)); + // The project list is owned ∪ shared: projects the user owns PLUS + // projects shared with one of their groups (any access). Visibility was + // previously owner-only (`eq(projects.userId, userId)`), which hid + // projects another user shared in via project_shares even though + // project.identify already reported them as {shared, access}. + const accessible = await getAccessibleProjects(userId, groupNames); + const accessById = new Map(accessible.map((p) => [p.projectId, p.access])); + const accessibleIds = accessible.map((p) => p.projectId); + + const rows = + accessibleIds.length === 0 + ? [] + : await db + .select({ + id: projects.id, + key: projects.key, + displayName: projects.displayName, + createdAt: projects.createdAt, + memoryCount: sql`count(${memories.id})::int`, + lastActivity: sql`max(${memories.createdAt})`, + }) + .from(projects) + .leftJoin( + memories, + and(eq(memories.projectId, projects.id), isNull(memories.deletedAt)), + ) + .where(inArray(projects.id, accessibleIds)) + .groupBy(projects.id) + .orderBy(desc(sql`max(${memories.createdAt})`)); return ( @@ -57,6 +71,9 @@ export default async function ProjectsPage() {
{p.key} {p.memoryCount} + {accessById.get(p.id) !== "owner" ? ( + shared · {accessById.get(p.id)} + ) : null}
{p.displayName && p.displayName !== p.key ? (
{p.displayName}
From b3f7e6006e19eb56bc030c6b0f6aaa87e9d9ab63 Mon Sep 17 00:00:00 2001 From: John Knapp Date: Fri, 12 Jun 2026 11:46:44 -0700 Subject: [PATCH 3/5] fix: snippet.list tag filter (same array-binding bug as memory.list) (#1) Replace the raw `${snippets.tags} @> ${tags}::text[]` template with Drizzle's arrayContains, matching the memory.list fix. The raw template expanded the JS array into positional params, producing a malformed array literal (one tag) / record-cast error (two tags) at runtime. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/lib/snippets.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/web/lib/snippets.ts b/apps/web/lib/snippets.ts index 668aab8..4fba1a7 100644 --- a/apps/web/lib/snippets.ts +++ b/apps/web/lib/snippets.ts @@ -1,4 +1,4 @@ -import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm"; +import { and, arrayContains, desc, eq, inArray, isNull, or } from "drizzle-orm"; import { db } from "@/lib/db/client"; import { snippets, projects } from "@/lib/db/schema"; import type { Snippet } from "@/lib/db/schema"; @@ -349,7 +349,13 @@ export async function listSnippets( } if (tags && tags.length > 0) { - where.push(sql`${snippets.tags} @> ${tags}::text[]`); + // Require ALL listed tags (array containment). Use Drizzle's + // arrayContains so the JS array binds as a single text[] param + // (via the column's toDriver) rather than being expanded into + // positional params — a raw `${tags}::text[]` template expands to + // `($1)::text[]` / `($1,$2)::text[]`, which Postgres rejects as a + // malformed array literal / record cast. + where.push(arrayContains(snippets.tags, tags)); } const rows = await db From 684ff03db24d61a89bd80f8f7bcb957cd5249081 Mon Sep 17 00:00:00 2001 From: John Knapp Date: Fri, 12 Jun 2026 11:47:20 -0700 Subject: [PATCH 4/5] feat: make CLI token TTL configurable (CLI_TOKEN_TTL_DAYS, default 90d) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI tokens were hardcoded to a 30-day expiry. Make the lifetime configurable via the CLI_TOKEN_TTL_DAYS env var, with a longer default of 90 days. The value must be a positive integer number of days; unset or invalid input falls back to 90. All other token claims are unchanged. Only affects newly minted tokens — already-issued tokens keep their original exp. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 5 +++++ apps/web/lib/auth/cli-token.ts | 21 ++++++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index abd8a1f..b149c68 100644 --- a/.env.example +++ b/.env.example @@ -70,6 +70,11 @@ NEXTAUTH_SECRET=replace-me-with-32-bytes-of-random # ----------------------------------------------------------------------------- CLI_TOKEN_SECRET=replace-me-with-32-bytes-of-random +# Lifetime (in days) of newly minted CLI tokens. Positive integer; unset or +# invalid values fall back to 90. Only affects tokens minted after this is set — +# already-issued tokens keep their original expiry. +# CLI_TOKEN_TTL_DAYS=90 + # ----------------------------------------------------------------------------- # App # ----------------------------------------------------------------------------- diff --git a/apps/web/lib/auth/cli-token.ts b/apps/web/lib/auth/cli-token.ts index 420e1a3..02fece3 100644 --- a/apps/web/lib/auth/cli-token.ts +++ b/apps/web/lib/auth/cli-token.ts @@ -29,7 +29,26 @@ import { cliTokens } from "@/lib/db/schema"; export const CLI_TOKEN_KID = "cli-v1"; export const CLI_TOKEN_ISSUER = "shared-memory:cli"; -export const CLI_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30; // 30 days + +// Default lifetime for newly minted CLI tokens. Overridable via the +// CLI_TOKEN_TTL_DAYS env var (must be a positive integer number of days); +// anything unset/invalid falls back to this default. Only affects tokens +// minted from now on — already-issued tokens keep their original `exp`. +const DEFAULT_CLI_TOKEN_TTL_DAYS = 90; + +function cliTokenTtlSeconds(): number { + const raw = process.env.CLI_TOKEN_TTL_DAYS; + let days = DEFAULT_CLI_TOKEN_TTL_DAYS; + if (raw !== undefined && raw.trim() !== "") { + const parsed = Number(raw); + if (Number.isInteger(parsed) && parsed > 0) { + days = parsed; + } + } + return days * 60 * 60 * 24; +} + +export const CLI_TOKEN_TTL_SECONDS = cliTokenTtlSeconds(); function secret(): Uint8Array { return new TextEncoder().encode(env().CLI_TOKEN_SECRET); From af1a6c8165552c514c038594dc7037986d7b3ecd Mon Sep 17 00:00:00 2001 From: jknapp Date: Fri, 12 Jun 2026 12:16:25 -0700 Subject: [PATCH 5/5] fix(compose): app healthcheck uses 127.0.0.1 not localhost Inside the app container localhost resolves to ::1 (IPv6) first, but the Next.js standalone server listens only on 0.0.0.0 (IPv4). The healthcheck probed http://localhost:3000/api/health and got Connection refused on ::1, so the container reported unhealthy for weeks despite serving 200 on both / and /api/health. Switch the probe to 127.0.0.1 to match the bound iface. The db and embedder healthchecks already avoid localhost. Co-Authored-By: Claude Opus 4.8 (1M context) --- docker-compose.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index cfcb437..0b4307b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -129,7 +129,11 @@ services: # but you can bind to 127.0.0.1 only by setting APP_BIND=127.0.0.1. - "${APP_BIND:-0.0.0.0}:${APP_PORT:-3000}:3000" healthcheck: - test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:3000/api/health || exit 1"] + # Use 127.0.0.1, not localhost: inside the container localhost resolves + # to ::1 (IPv6) first, but the Next.js standalone server listens only on + # 0.0.0.0 (IPv4), so a localhost probe gets "Connection refused" and the + # container is reported unhealthy even though the app serves fine. + test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:3000/api/health || exit 1"] interval: 15s timeout: 5s retries: 5