Merge branch 'main' into docs/memory-snippet-reuse-policy
This commit is contained in:
@@ -70,6 +70,11 @@ NEXTAUTH_SECRET=replace-me-with-32-bytes-of-random
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
CLI_TOKEN_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
|
# App
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import Link from "next/link";
|
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 { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db/client";
|
import { db } from "@/lib/db/client";
|
||||||
import { memories, projects } from "@/lib/db/schema";
|
import { memories, projects } from "@/lib/db/schema";
|
||||||
|
import { getAccessibleProjects, getUserGroupNames } from "@/lib/access";
|
||||||
import { Container, PageHeader } from "@/app/_components/ui/container";
|
import { Container, PageHeader } from "@/app/_components/ui/container";
|
||||||
import { Card } from "@/app/_components/ui/card";
|
import { Card } from "@/app/_components/ui/card";
|
||||||
import { Badge } from "@/app/_components/ui/badge";
|
import { Badge } from "@/app/_components/ui/badge";
|
||||||
@@ -13,8 +14,21 @@ export const dynamic = "force-dynamic";
|
|||||||
export default async function ProjectsPage() {
|
export default async function ProjectsPage() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const userId = session!.user.id;
|
const userId = session!.user.id;
|
||||||
|
const groupNames = await getUserGroupNames(userId);
|
||||||
|
|
||||||
const rows = await db
|
// 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({
|
.select({
|
||||||
id: projects.id,
|
id: projects.id,
|
||||||
key: projects.key,
|
key: projects.key,
|
||||||
@@ -28,7 +42,7 @@ export default async function ProjectsPage() {
|
|||||||
memories,
|
memories,
|
||||||
and(eq(memories.projectId, projects.id), isNull(memories.deletedAt)),
|
and(eq(memories.projectId, projects.id), isNull(memories.deletedAt)),
|
||||||
)
|
)
|
||||||
.where(eq(projects.userId, userId))
|
.where(inArray(projects.id, accessibleIds))
|
||||||
.groupBy(projects.id)
|
.groupBy(projects.id)
|
||||||
.orderBy(desc(sql`max(${memories.createdAt})`));
|
.orderBy(desc(sql`max(${memories.createdAt})`));
|
||||||
|
|
||||||
@@ -57,6 +71,9 @@ export default async function ProjectsPage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-mono text-sm text-fg truncate">{p.key}</span>
|
<span className="font-mono text-sm text-fg truncate">{p.key}</span>
|
||||||
<Badge>{p.memoryCount}</Badge>
|
<Badge>{p.memoryCount}</Badge>
|
||||||
|
{accessById.get(p.id) !== "owner" ? (
|
||||||
|
<Badge tone="accent">shared · {accessById.get(p.id)}</Badge>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{p.displayName && p.displayName !== p.key ? (
|
{p.displayName && p.displayName !== p.key ? (
|
||||||
<div className="text-xs text-fg-muted truncate mt-0.5">{p.displayName}</div>
|
<div className="text-xs text-fg-muted truncate mt-0.5">{p.displayName}</div>
|
||||||
|
|||||||
@@ -29,7 +29,26 @@ import { cliTokens } from "@/lib/db/schema";
|
|||||||
|
|
||||||
export const CLI_TOKEN_KID = "cli-v1";
|
export const CLI_TOKEN_KID = "cli-v1";
|
||||||
export const CLI_TOKEN_ISSUER = "shared-memory:cli";
|
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 {
|
function secret(): Uint8Array {
|
||||||
return new TextEncoder().encode(env().CLI_TOKEN_SECRET);
|
return new TextEncoder().encode(env().CLI_TOKEN_SECRET);
|
||||||
|
|||||||
@@ -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 { db } from "@/lib/db/client";
|
||||||
import {
|
import {
|
||||||
memories,
|
memories,
|
||||||
@@ -465,7 +465,13 @@ const memoryList: ToolDef = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.data.tags && parsed.data.tags.length > 0) {
|
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
|
const rows = await db
|
||||||
|
|||||||
@@ -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 { db } from "@/lib/db/client";
|
||||||
import { snippets, projects } from "@/lib/db/schema";
|
import { snippets, projects } from "@/lib/db/schema";
|
||||||
import type { Snippet } from "@/lib/db/schema";
|
import type { Snippet } from "@/lib/db/schema";
|
||||||
@@ -349,7 +349,13 @@ export async function listSnippets(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (tags && tags.length > 0) {
|
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
|
const rows = await db
|
||||||
|
|||||||
+5
-1
@@ -129,7 +129,11 @@ services:
|
|||||||
# but you can bind to 127.0.0.1 only by setting APP_BIND=127.0.0.1.
|
# 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"
|
- "${APP_BIND:-0.0.0.0}:${APP_PORT:-3000}:3000"
|
||||||
healthcheck:
|
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
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|||||||
Reference in New Issue
Block a user