diff --git a/.github/scripts/eslint-baseline.sh b/.github/scripts/eslint-baseline.sh new file mode 100755 index 0000000..40bacd6 --- /dev/null +++ b/.github/scripts/eslint-baseline.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Fail only if ESLint reports MORE errors than today's baseline (a ratchet). +# +# eslint-baseline.sh # run inside an app directory +# +# `npm run lint` already exits 1 on main (pre-existing errors, listed nowhere in +# AGENTS.md's baseline table), so a plain lint gate would be red from day one. +# This keeps that debt from growing: lower the number when you fix errors, +# never raise it to make a red build green. Warnings are not counted. +set -uo pipefail + +max="${1:?usage: eslint-baseline.sh }" + +json="$(npx eslint . --format json 2>/dev/null)" +errors="$(printf '%s' "$json" | node -e ' + let s = ""; + process.stdin.on("data", d => (s += d)).on("end", () => { + try { console.log(JSON.parse(s).reduce((n, f) => n + f.errorCount, 0)); } + catch { console.log("crashed"); } + });')" + +if [[ "$errors" == "crashed" ]]; then + echo "eslint-baseline: ESLint did not produce a report (config/parse crash):" >&2 + npx eslint . 2>&1 | tail -n 20 >&2 + exit 1 +fi + +echo "eslint-baseline: $errors error(s) (baseline allows $max)" +if (( errors > max )); then + echo "eslint-baseline: FAIL: $((errors - max)) new lint error(s) over the baseline of $max" >&2 + npx eslint . --quiet 2>&1 | tail -n 60 >&2 + exit 1 +fi +echo "eslint-baseline: OK" diff --git a/.github/scripts/pytest-baseline.sh b/.github/scripts/pytest-baseline.sh new file mode 100755 index 0000000..da67928 --- /dev/null +++ b/.github/scripts/pytest-baseline.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Run a pytest command and fail only on NEW failures. +# +# pytest-baseline.sh -- +# +# AGENTS.md §5 lists known, pre-existing failures per service (they need env +# vars or a live Postgres/Redis that CI doesn't have). This passes while the +# number of failed + errored tests is <= that baseline, and fails the moment +# it goes above it. Lower the number when you fix one; never raise it to make a +# red build green. +set -uo pipefail + +max="${1:?usage: pytest-baseline.sh -- }" +shift +[[ "${1:-}" == "--" ]] && shift + +out="$("$@" 2>&1)" +rc=$? +printf '%s\n' "$out" | tail -n 60 + +if [[ $rc -eq 0 ]]; then + echo "pytest-baseline: all tests passed" + exit 0 +fi +if [[ $rc -ne 1 ]]; then + echo "pytest-baseline: pytest exited $rc (usage error / interrupted / no tests), not a plain test failure" >&2 + exit "$rc" +fi + +summary="$(printf '%s\n' "$out" | grep -E '[0-9]+ (passed|failed|error)' | tail -n 1)" +bad="$(printf '%s' "$summary" | grep -oE '[0-9]+ (failed|errors?)' | awk '{s += $1} END {print s + 0}')" +echo "pytest-baseline: $bad failed/errored (baseline allows $max) — $summary" + +if (( bad > max )); then + echo "pytest-baseline: FAIL: $((bad - max)) new failure(s) over the baseline of $max" >&2 + printf '%s\n' "$out" | grep -E '^(FAILED|ERROR)' >&2 + exit 1 +fi +echo "pytest-baseline: OK (only the known baseline failures)" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..11ff349 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,97 @@ +# CI for the monorepo: web apps (lint ratchet, typecheck, build) and the three +# Python services (tests against AGENTS.md §5's known baselines). Never +# deploys. deploy-single-box.yml calls this before deploying `main`. +# +# The two gates are ratchets, not zero-tolerance: main has pre-existing failures +# (AGENTS.md §5 baselines; ESLint errors that AGENTS.md doesn't list). They fail +# only on NEW ones. Lower a number when you fix something; never raise it to turn +# a red build green. packages/db has its own workflow (db.yml). +name: ci + +# Runs on pull requests, and is called by deploy-single-box.yml for pushes to +# main and dev (so a push isn't checked twice). +on: + pull_request: + branches: [main, dev] + workflow_call: + +permissions: + contents: read + +jobs: + web: + name: web (${{ matrix.app }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - { app: persona-web, lint_baseline: 24 } + - { app: cards-web, lint_baseline: 3 } + defaults: + run: + working-directory: apps/${{ matrix.app }} + env: + # Placeholders so `next build` can prerender. None of these are secrets. + NEXT_PUBLIC_SUPABASE_URL: https://ci-placeholder.supabase.co + NEXT_PUBLIC_SUPABASE_ANON_KEY: ci-placeholder-anon-key + NEXT_PUBLIC_API_URL: https://api.example.test + NEXT_PUBLIC_SITE_URL: https://example.test + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: apps/${{ matrix.app }}/package-lock.json + - run: npm ci --no-audit --no-fund + - name: Lint (ratchet) + run: bash "$GITHUB_WORKSPACE/.github/scripts/eslint-baseline.sh" ${{ matrix.lint_baseline }} + - name: Typecheck + run: npx tsc --noEmit + - name: Build + # Turbopack first, like production. persona-web is pinned to next@16.2.1, + # whose Turbopack font handler fails on some Google Fonts responses + # ("next/font/google queries have exactly one entry": seen on a GitHub + # runner for Geist; the same build passes elsewhere, and cards-web on + # next@16.3.6 is unaffected). If that happens, retry with webpack: a real + # code error fails both builds and stays red; only the bundler bug is + # let through, with a warning. Fix at the source by bumping persona-web's + # Next to the version cards-web uses. + run: | + npm run build && exit 0 + echo "::warning title=Turbopack build failed::retrying with webpack to tell a code error from the next/font/google Turbopack bug on old Next" + npm run build -- --webpack + + python: + name: python (${{ matrix.service }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + # baseline = the known failures in AGENTS.md §5 (measured on a clean checkout with no .env) + - service: persona-api + baseline: 5 + install: pip install -r requirements.txt pytest pytest-asyncio + test: python -m pytest -q + - service: cards-api + baseline: 2 + install: pip install -r requirements.txt pytest pytest-asyncio + test: python -m pytest -q + - service: memory + baseline: 9 + install: pip install uv && uv sync + test: uv run pytest -q -m "not integration" + defaults: + run: + working-directory: services/${{ matrix.service }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install + run: ${{ matrix.install }} + - name: Tests (no new failures over the baseline) + run: bash "$GITHUB_WORKSPACE/.github/scripts/pytest-baseline.sh" ${{ matrix.baseline }} -- ${{ matrix.test }} diff --git a/.github/workflows/deploy-single-box.yml b/.github/workflows/deploy-single-box.yml new file mode 100644 index 0000000..7e10145 --- /dev/null +++ b/.github/workflows/deploy-single-box.yml @@ -0,0 +1,139 @@ +# CD for the single box (infra/single-box/README.md). The box runs two +# environments off the same code: `main` -> prod, `dev` -> dev. When either +# branch changes, run the CI checks, then SSH to the box and run +# infra/single-box/deploy.sh for that environment, which rebuilds and restarts +# only the services whose files changed and health-checks them. Never applies +# DB migrations (packages/db is applied by a person). +# +# Manual runs (Actions tab -> Run workflow) skip the checks so a rollback isn't +# blocked by them: pick the environment, and give a commit sha on that branch to +# roll to, `force_all` to rebuild everything, or `plan_only` to see what a +# deploy would do. +# +# Needs three repository secrets (see README, "CI/CD"): DEPLOY_HOST, +# DEPLOY_SSH_KEY, DEPLOY_HOST_KEY. Only `push` to main/dev and manual dispatch +# trigger this, never pull requests, so forks can't reach the secrets. +name: deploy-single-box + +on: + push: + branches: [main, dev] + workflow_dispatch: + inputs: + environment: + description: 'Which instance to deploy: prod (branch main) or dev (branch dev)' + type: choice + options: [prod, dev] + default: prod + ref: + description: 'Commit sha on that environment''s branch (blank = latest; an older sha rolls back)' + required: false + default: '' + force_all: + description: 'Rebuild and restart every service' + type: boolean + default: false + plan_only: + description: 'Show what would change; deploy nothing' + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: deploy-single-box + cancel-in-progress: false + +jobs: + checks: + if: github.event_name == 'push' + uses: ./.github/workflows/ci.yml + + deploy: + needs: checks + # `checks` is skipped on manual runs; a skipped need would otherwise skip us too. + if: ${{ !cancelled() && (needs.checks.result == 'success' || needs.checks.result == 'skipped') }} + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Build the deploy arguments + id: args + env: + # push to dev -> dev instance, push to main -> prod; a manual run picks. + ENVIRONMENT: ${{ github.event_name == 'workflow_dispatch' && inputs.environment || (github.ref_name == 'dev' && 'dev' || 'prod') }} + REF: ${{ inputs.ref }} + FORCE_ALL: ${{ inputs.force_all }} + PLAN_ONLY: ${{ inputs.plan_only }} + run: | + [[ "$ENVIRONMENT" == "prod" || "$ENVIRONMENT" == "dev" ]] || { echo "::error::unknown environment '$ENVIRONMENT'"; exit 1; } + args="latest" + if [[ -n "$REF" ]]; then + [[ "$REF" =~ ^[0-9a-f]{7,40}$ ]] || { echo "::error::ref must be a commit sha (7-40 hex characters)"; exit 1; } + args="$REF" + fi + args="$args --env=$ENVIRONMENT" + [[ "$FORCE_ALL" == "true" ]] && args="$args --force-all" + [[ "$PLAN_ONLY" == "true" ]] && args="$args --plan" + echo "Deploying the $ENVIRONMENT instance: $args" + echo "value=$args" >> "$GITHUB_OUTPUT" + + - name: Set up SSH + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + DEPLOY_HOST_KEY: ${{ secrets.DEPLOY_HOST_KEY }} + run: | + missing=() + for v in DEPLOY_HOST DEPLOY_SSH_KEY DEPLOY_HOST_KEY; do [[ -n "${!v}" ]] || missing+=("$v"); done + if ((${#missing[@]})); then + echo "::error::Missing repository secret(s): ${missing[*]}. See infra/single-box/README.md, CI/CD." + exit 1 + fi + umask 077 + install -d ~/.ssh + printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key + + # Sanity-check the private key without printing any of it. + head -n 1 ~/.ssh/deploy_key | grep -q -- '-----BEGIN OPENSSH PRIVATE KEY-----' \ + || { echo "::error::DEPLOY_SSH_KEY must be the whole private key file, starting with the -----BEGIN OPENSSH PRIVATE KEY----- line"; exit 1; } + ssh-keygen -y -f ~/.ssh/deploy_key >/dev/null 2>&1 \ + || { echo "::error::DEPLOY_SSH_KEY is not a valid private key (missing END line, or line breaks lost when pasting?)"; exit 1; } + + # Pinned host key, not trust-on-first-use. Take " " from whatever + # was pasted, so a leading host name, a trailing comment or quotes don't break it. + hostkey="$(printf '%s' "$DEPLOY_HOST_KEY" | tr -d '\r"'"'" | awk ' + { for (i = 1; i < NF; i++) if ($i ~ /^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp(256|384|521))$/) { print $i, $(i+1); exit } }')" + if [[ -z "$hostkey" ]]; then + # Only the base64 part was pasted: every ed25519 host key starts with this fixed prefix. + b64="$(printf '%s' "$DEPLOY_HOST_KEY" | tr -d '\r"'"'" | grep -oE 'AAAAC3NzaC1lZDI1NTE5[A-Za-z0-9+/=]+' | head -n 1 || true)" + [[ -n "$b64" ]] && hostkey="ssh-ed25519 $b64" + fi + if [[ -z "$hostkey" ]]; then + echo "::error::DEPLOY_HOST_KEY has no host key in it. It must be one line, 'ssh-ed25519 AAAA...', i.e. the output of: cut -d' ' -f1-2 /etc/ssh/ssh_host_ed25519_key.pub" + exit 1 + fi + printf '%s %s\n' "$DEPLOY_HOST" "$hostkey" > ~/.ssh/known_hosts + + # Compare with what the server actually presents, and say so if they differ + # (fingerprints of public host keys only). + if [[ "$hostkey" == ssh-ed25519* ]]; then + scanned="$(ssh-keyscan -T 10 -t ed25519 "$DEPLOY_HOST" 2>/dev/null | awk '{print $2, $3}' | head -n 1)" + if [[ -z "$scanned" ]]; then + echo "::warning::could not reach the server on port 22 to double-check the pinned host key" + elif [[ "$scanned" != "$hostkey" ]]; then + echo "::error::DEPLOY_HOST_KEY does not match the key the server presents. Pinned: $(echo "$hostkey" | ssh-keygen -lf - | cut -d' ' -f2) Server: $(echo "$scanned" | ssh-keygen -lf - | cut -d' ' -f2)" + exit 1 + fi + fi + + - name: Deploy + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + ARGS: ${{ steps.args.outputs.value }} + run: | + # The key on the server is restricted to a forced command (deploy.sh), so + # these arguments are all it will ever accept. + ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes -o BatchMode=yes \ + -o StrictHostKeyChecking=yes -o ConnectTimeout=20 -o ServerAliveInterval=30 \ + "ubuntu@${DEPLOY_HOST}" "$ARGS" diff --git a/apps/cards-web/README.md b/apps/cards-web/README.md index 20df47e..7ec9fcb 100644 --- a/apps/cards-web/README.md +++ b/apps/cards-web/README.md @@ -2,7 +2,7 @@ The standalone frontend for **cards.zynd.ai** — ported out of the `dashboard` repo's `app/(site)/{p,create,profile,directory,find,search,tag,for-ai}` and -`app/agent-card`, per `ZYND_CARDS_MOVE_PLAN.md` phase P2. It talks to the +`app/agent-card` (now the `/` landing page, `app/(landing)`), per `ZYND_CARDS_MOVE_PLAN.md` phase P2. It talks to the existing `services/cards-api` (still `api.zynd.ai`, unchanged) for card data, and to a Supabase project for login. @@ -43,7 +43,7 @@ production auth yet: `www.zynd.ai` — the dashboard's versions mixed in registry/blog content that doesn't belong here. `api/indexnow` uses its own IndexNow key (Bing requires the key file to be hosted on the exact host it's submitted for). -- Everything else — `p/[handle]/**`, `agent-card/**`, `profile/[id]`, +- Everything else — `p/[handle]/**`, the `(landing)` page, `profile/[id]`, `directory`, `find`, `search`, `tag/[skill]`, `for-ai`, `lib/cards.ts`, `lib/memory*.ts`, `lib/claim-tokens.ts`, `lib/supabase/*`, `components/memory/*`, `ProfileChatWidget`, `useMyCard` — ported with no diff --git a/apps/cards-web/next.config.ts b/apps/cards-web/next.config.ts index cc79966..f4e21ba 100644 --- a/apps/cards-web/next.config.ts +++ b/apps/cards-web/next.config.ts @@ -1,6 +1,11 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + // The landing page used to live at /agent-card (as it still does on the + // dashboard); here it is the site root. + async redirects() { + return [{ source: "/agent-card", destination: "/", permanent: true }]; + }, async headers() { return [ { diff --git a/apps/cards-web/src/app/agent-card/agent-card.css b/apps/cards-web/src/app/(landing)/agent-card.css similarity index 100% rename from apps/cards-web/src/app/agent-card/agent-card.css rename to apps/cards-web/src/app/(landing)/agent-card.css diff --git a/apps/cards-web/src/app/agent-card/auth-bar.tsx b/apps/cards-web/src/app/(landing)/auth-bar.tsx similarity index 96% rename from apps/cards-web/src/app/agent-card/auth-bar.tsx rename to apps/cards-web/src/app/(landing)/auth-bar.tsx index 45b48a0..7bd4590 100644 --- a/apps/cards-web/src/app/agent-card/auth-bar.tsx +++ b/apps/cards-web/src/app/(landing)/auth-bar.tsx @@ -11,7 +11,7 @@ export function AgentCardAuthBar() { const { ready, authenticated, handle } = useMyCard(); function signIn() { - setAuthNext("/agent-card", "card"); + setAuthNext("/", "card"); createClient().auth.signInWithOAuth({ provider: "linkedin_oidc", options: { redirectTo: CALLBACK() }, @@ -21,7 +21,7 @@ export function AgentCardAuthBar() { async function signOut() { const { error } = await createClient().auth.signOut(); if (error) console.error("Sign out failed:", error); - window.location.href = "/agent-card"; + window.location.href = "/"; } return ( diff --git a/apps/cards-web/src/app/agent-card/hero-card-stack.tsx b/apps/cards-web/src/app/(landing)/hero-card-stack.tsx similarity index 100% rename from apps/cards-web/src/app/agent-card/hero-card-stack.tsx rename to apps/cards-web/src/app/(landing)/hero-card-stack.tsx diff --git a/apps/cards-web/src/app/agent-card/layout.tsx b/apps/cards-web/src/app/(landing)/layout.tsx similarity index 81% rename from apps/cards-web/src/app/agent-card/layout.tsx rename to apps/cards-web/src/app/(landing)/layout.tsx index 28c883d..0bc9e84 100644 --- a/apps/cards-web/src/app/agent-card/layout.tsx +++ b/apps/cards-web/src/app/(landing)/layout.tsx @@ -6,14 +6,14 @@ export const metadata: Metadata = { title: "Zynd — The Living Professional Identity for Technical Builders and Agents", description: "Zynd synthesizes GitHub, LinkedIn, X and your website into one living professional profile — browsable by people, searchable by AI agents.", - alternates: { canonical: "/agent-card" }, + alternates: { canonical: "/" }, }; /** - * Standalone root layout. `/agent-card` deliberately sits OUTSIDE the `(site)` - * route group so it does not inherit globals.css / zynd-ui.css — the page ships - * its own compiled Tailwind v3 stylesheet and would otherwise fight the app's - * Tailwind 4 preflight. + * Standalone root layout for `/`. The landing page deliberately sits OUTSIDE + * the `(site)` route group so it does not inherit globals.css / zynd-ui.css — + * the page ships its own compiled Tailwind v3 stylesheet and would otherwise + * fight the app's Tailwind 4 preflight. */ export default function AgentCardLayout({ children, diff --git a/apps/cards-web/src/app/agent-card/page.tsx b/apps/cards-web/src/app/(landing)/page.tsx similarity index 99% rename from apps/cards-web/src/app/agent-card/page.tsx rename to apps/cards-web/src/app/(landing)/page.tsx index 7ab37ab..2cb9a32 100644 --- a/apps/cards-web/src/app/agent-card/page.tsx +++ b/apps/cards-web/src/app/(landing)/page.tsx @@ -11,7 +11,7 @@ import { Typewriter } from "./typewriter"; import { AgentCardAuthBar } from "./auth-bar"; /** - * `/agent-card` — the standalone Zynd landing page. + * `/` — the standalone Zynd landing page. * * Static marketing markup, with two live hooks into the create flow: * - every "Create your Living Profile" / "Claim Handle" CTA links to /create diff --git a/apps/cards-web/src/app/agent-card/typewriter.tsx b/apps/cards-web/src/app/(landing)/typewriter.tsx similarity index 100% rename from apps/cards-web/src/app/agent-card/typewriter.tsx rename to apps/cards-web/src/app/(landing)/typewriter.tsx diff --git a/apps/cards-web/src/app/auth/callback/route.ts b/apps/cards-web/src/app/(site)/auth/callback/route.ts similarity index 65% rename from apps/cards-web/src/app/auth/callback/route.ts rename to apps/cards-web/src/app/(site)/auth/callback/route.ts index bb697fe..ebb5adf 100644 --- a/apps/cards-web/src/app/auth/callback/route.ts +++ b/apps/cards-web/src/app/(site)/auth/callback/route.ts @@ -2,13 +2,32 @@ import { NextRequest, NextResponse } from "next/server"; import { createServerClient } from "@supabase/ssr"; import { safeNextPath } from "@/lib/auth/next-cookie"; +const HOST_RE = /^[a-z0-9.-]+(:\d+)?$/i; + +/** + * The origin the browser is actually on. Behind Caddy, `next start` builds + * request.url from its own bind address (https://localhost:3102 on dev, + * https://0.0.0.0:3002 on prod), so redirecting there strands the user. + * Caddy passes the real Host through and replaces any client-sent + * X-Forwarded-* headers; without a proxy (`next dev`) request.url is right. + */ +function publicOrigin(request: NextRequest): string { + const fallback = new URL(request.url); + const host = (request.headers.get("x-forwarded-host") ?? request.headers.get("host") ?? "").split(",")[0].trim(); + if (!HOST_RE.test(host)) return fallback.origin; + const forwardedProto = request.headers.get("x-forwarded-proto")?.split(",")[0].trim(); + const proto = forwardedProto === "https" || forwardedProto === "http" ? forwardedProto : fallback.protocol.slice(0, -1); + return `${proto}://${host}`; +} + /** * OAuth (LinkedIn, D12) callback. Deliberately simpler than the dashboard's: * no Prisma developer lookup, no destinationAfterLogin routing table — just * "go back where the user was trying to go, or the directory." */ export async function GET(request: NextRequest) { - const { searchParams, origin } = new URL(request.url); + const { searchParams } = new URL(request.url); + const origin = publicOrigin(request); const code = searchParams.get("code"); const cookieNext = safeNextPath(request.cookies.get("zynd_next")?.value); const queryNext = safeNextPath(searchParams.get("next")); diff --git a/apps/cards-web/src/app/auth/page.tsx b/apps/cards-web/src/app/(site)/auth/page.tsx similarity index 100% rename from apps/cards-web/src/app/auth/page.tsx rename to apps/cards-web/src/app/(site)/auth/page.tsx diff --git a/apps/cards-web/src/app/create/page.tsx b/apps/cards-web/src/app/(site)/create/page.tsx similarity index 87% rename from apps/cards-web/src/app/create/page.tsx rename to apps/cards-web/src/app/(site)/create/page.tsx index bed2f06..b319e2a 100644 --- a/apps/cards-web/src/app/create/page.tsx +++ b/apps/cards-web/src/app/(site)/create/page.tsx @@ -114,8 +114,8 @@ const QUESTIONS: { id: string; label: string; type: QuestionType; options?: stri label: "Where are you based?", }, { - id: "calendly_url", type: "text", - label: "Got a Calendly?", + id: "booking", type: "text", + label: "Got a booking link?", }, ]; @@ -373,6 +373,7 @@ function CreateProfilePageContent() { }); const [locationInput, setLocationInput] = useState(""); const [calendlyInput, setCalendlyInput] = useState(""); + const [googleCalInput, setGoogleCalInput] = useState(""); const [jobDone, setJobDone] = useState(false); const [existingHandle, setExistingHandle] = useState(null); @@ -454,7 +455,7 @@ function CreateProfilePageContent() { }); }, [authenticated, editHandle]); - // Seeded from the /agent-card paste bar: `?url=` lands here as the + // Seeded from the landing page paste bar: `?url=` lands here as the // first source chip so the visitor never retypes what they already pasted. const seededUrl = searchParams.get("url"); const seededRef = useRef(false); @@ -465,7 +466,7 @@ function CreateProfilePageContent() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [seededUrl]); - // Pre-fill the custom handle field when the user arrives from /agent-card + // Pre-fill the custom handle field when the user arrives from the landing page // with ?handle= — they typed it there so honour it exactly. const seededHandle = searchParams.get("handle"); const seededHandleRef = useRef(false); @@ -659,8 +660,9 @@ function CreateProfilePageContent() { if (parts.length > 0) userAnswers[q.id] = parts.join(", "); } else if (q.id === "location" && locationInput.trim()) { userAnswers["location"] = locationInput.trim(); - } else if (q.id === "calendly_url" && calendlyInput.trim()) { - userAnswers["calendly_url"] = calendlyInput.trim(); + } else if (q.id === "booking") { + if (calendlyInput.trim()) userAnswers["calendly_url"] = calendlyInput.trim(); + if (googleCalInput.trim()) userAnswers["google_calendar_url"] = googleCalInput.trim(); } } const res = await fetch(`${CARDS_API}/onboard/${jobId}/publish`, { @@ -1019,18 +1021,82 @@ function CreateProfilePageContent() { .zc-panel { padding: 28px 26px; gap: 24px; } .zc-root h1.zc-panel-title { font-size: 40px; } .zc-step-card { flex: none; } + /* Stacked layout: let the page grow and scroll naturally. With the + fixed-height shell the panel + form were squeezed into whatever + height was left between the header and footer (the form ended up + in a ~190px window scrolling inside itself). */ + .zc-root { height: auto; min-height: 100dvh; overflow: visible; display: flex; flex-direction: column; } + .zc-shell { flex: 1 0 auto; height: auto; overflow: visible; } + .zc-grid { flex: none; } + .zc-col { overflow: visible; } } @media (max-width: 640px) { - .zc-root { padding: 16px 12px 56px; } - .zc-shell { border-radius: 26px; padding: 20px 16px 24px; gap: 16px; } - .zc-panel { border-radius: 22px; } - .zc-root h1.zc-panel-title { font-size: 34px; } + .zc-root { padding: 12px 10px 20px; } + .zc-shell { border-radius: 26px; padding: 18px 12px 22px; gap: 16px; } + .zc-grid, .zc-col { gap: 12px; } + .zc-panel { border-radius: 22px; padding: 22px 18px 18px; gap: 18px; } + .zc-root h1.zc-panel-title { font-size: 30px; } .zc-question { font-size: 26px; } - .zc-card { border-radius: 22px; } - .zc-ctarow { grid-template-columns: minmax(0,1fr); } - .zc-inputbox { padding: 16px; } - .zc-inputbox input { font-size: 13px !important; } + .zc-card { border-radius: 22px; padding: 20px 18px !important; } + .zc-live-card { padding: 28px 18px 24px !important; gap: 18px !important; } + .zc-ctarow { grid-template-columns: minmax(0,1fr); gap: 12px; } + .zc-inputbox { padding: 14px 16px !important; } .zc-cta { padding: 20px 22px !important; } + + /* never overflow sideways: long URLs / handles / error text wrap */ + .zc-root p, .zc-root h1, .zc-root .zc-question, .zc-row-main { overflow-wrap: anywhere; } + .zc-chipbox { max-width: 100%; min-width: 0; } + .zc-clip { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + /* 16px+ inputs — iOS Safari zooms the page on focus below that */ + .zc-root input:not([type="file"]), .zc-root textarea { font-size: 16px !important; min-width: 0; } + + /* tap targets: grow the hit area of tiny text buttons without moving + anything (padding out, matching negative margin back in) */ + .zc-x, .zc-rowbtn, .zc-tap { padding: 14px 12px !important; margin: -14px -12px !important; } + .zc-tap-end { margin-left: auto !important; } + .zc-btn, .zc-quick { min-height: 40px; } + .zc-dirlink { padding: 13px 6px; margin: -13px -6px; } + .zc-pencil::after { content: ""; position: absolute; inset: -10px; } + .zc-photo-actions { gap: 24px !important; } + + /* header / panel */ + .zc-badge { padding: 8px 13px !important; max-width: 100%; } + .zc-badge-text { letter-spacing: .1em !important; line-height: 1.35 !important; } + .zc-panel-live { flex-wrap: wrap; gap: 8px 12px !important; } + .zc-handle-pill { max-width: 100%; overflow-wrap: anywhere; line-height: 1.35 !important; } + + /* questions */ + .zc-chips { gap: 8px !important; } + .zc-chips .zc-opt:not([aria-label]) { padding: 12px 15px !important; } + .zc-custom-input { flex: 1 1 140px; width: auto !important; } + .zc-bigfield { padding: 16px !important; border-radius: 16px !important; } + .zc-navrow, .zc-navgroup { gap: 8px !important; } + .zc-navrow button { padding: 13px 16px !important; } + + /* post-publish */ + .zc-live-pill { max-width: 100%; padding: 6px 6px 6px 16px !important; gap: 8px !important; } + .zc-live-url { min-width: 0; overflow-wrap: anywhere; font-size: 13px !important; line-height: 1.35 !important; text-align: left; } + .zc-copy { min-height: 40px; padding: 0 16px !important; } + + /* review */ + .zc-fixrow { flex-wrap: wrap !important; } + .zc-fixrow input { flex: 1 1 100% !important; } + .zc-row-post { flex-wrap: wrap; row-gap: 6px; } + .zc-row-label { width: auto !important; flex-basis: 100%; padding-top: 0 !important; } + .zc-headrow { flex-wrap: wrap; gap: 6px 12px; } + .zc-addrow { flex-wrap: wrap; } + .zc-addbox { flex: 1 1 100% !important; padding: 12px 14px !important; } + .zc-addrow > button { flex: 1 1 100% !important; } + /* custom-handle field: prefix above the input so long slugs have room */ + .zc-handle-prefix { position: static !important; transform: none !important; margin-bottom: 8px; font-size: 13px !important; } + .zc-handle-input { padding: 13px 92px 13px 15px !important; } + .zc-handle-status { top: auto !important; bottom: 18px !important; transform: none !important; } + + /* footer: stack the tagline and the three steps */ + .zc-foot { flex-direction: column; align-items: flex-start !important; gap: 16px !important; padding-top: 16px !important; } + .zc-foot-tag { padding-right: 0 !important; margin-right: 0 !important; border-right: none !important; } + .zc-foot-steps { flex: none !important; flex-direction: column; gap: 14px !important; width: 100%; } + .zc-foot-step { flex: none !important; padding-left: 0 !important; margin-left: 0 !important; border-left: none !important; } } @media (prefers-reduced-motion: reduce) { .zc-chip-enter { animation: none; } @@ -1048,7 +1114,7 @@ function CreateProfilePageContent() { Zynd Zynd Profile - ← Directory @@ -1060,9 +1126,9 @@ function CreateProfilePageContent() {
-
+
- {panel.badge} + {panel.badge}
@@ -1072,9 +1138,9 @@ function CreateProfilePageContent() {
-
+
{liveHandle ? "Live at" : "Publishes at"} - + zynd.ai/p/{liveHandle || (customHandle.length >= 2 ? customHandle : "you")}
@@ -1101,7 +1167,7 @@ function CreateProfilePageContent() { />
) : ( -
+
@@ -1114,9 +1180,9 @@ function CreateProfilePageContent() {

You're live

-
- zynd.ai/p/{published} - @@ -1226,8 +1292,8 @@ function CreateProfilePageContent() { {urls.length > 0 && (
{urls.map(url => ( -
- {shortenUrl(url)} +
+ {shortenUrl(url)}
@@ -1284,7 +1350,7 @@ function CreateProfilePageContent() { {resume ? resume.name : "Upload PDF or DOCX"} {resume && ( - )}
@@ -1360,7 +1426,7 @@ function CreateProfilePageContent() {
{q.label}
{q.type === "chips" && q.options && ( -
+
{q.options.map(opt => { const on = (selections[q.id] ?? new Set()).has(opt); return ( @@ -1393,6 +1459,7 @@ function CreateProfilePageContent() { { customRefs.current[q.id] = el; }} type="text" + className="zc-custom-input" value={customs[q.id] ?? ""} onChange={e => setCustoms(p => { const out = { ...p, [q.id]: e.target.value }; @@ -1438,7 +1505,7 @@ function CreateProfilePageContent() { onChange={e => setLocationInput(e.target.value)} placeholder="e.g. San Francisco, CA" autoFocus - className="zc-field" + className="zc-field zc-bigfield" style={{ width: "100%", padding: "20px 22px", borderRadius: "18px", border: `1px solid ${locationInput ? T.accent : T.border}`, @@ -1451,28 +1518,38 @@ function CreateProfilePageContent() {
)} - {q.type === "text" && q.id === "calendly_url" && ( -
- setCalendlyInput(e.target.value)} - placeholder="https://calendly.com/yourname" - autoFocus - className="zc-field" - style={{ - width: "100%", padding: "20px 22px", borderRadius: "18px", - border: `1px solid ${calendlyInput ? T.accent : T.border}`, - background: T.surface, color: T.ink, font: `400 15px/1 ${SANS}`, - outline: "none", boxSizing: "border-box", transition: "border-color .12s", - }} - onKeyDown={e => { if (e.key === "Enter") advanceQuestion(); }} - /> - optional — skip if you prefer + {q.type === "text" && q.id === "booking" && ( +
+ {[ + { label: "Calendly", value: calendlyInput, set: setCalendlyInput, placeholder: "https://calendly.com/yourname" }, + { label: "Google Calendar", value: googleCalInput, set: setGoogleCalInput, placeholder: "https://calendar.app.google/…" }, + ].map((f, i) => ( + + ))} + + optional — add either or both. For Google Calendar, share your appointment schedule's booking page link. +
)} -
+
-
+
{fixingUrl === w.url ? ( -
+
- -
) : ( - @@ -1629,7 +1706,7 @@ function CreateProfilePageContent() { {card.identity.avatar_url ? "Profile photo — from scraped sources" : "No photo found"} -
+
@@ -1658,7 +1735,7 @@ function CreateProfilePageContent() { } }} /> - @@ -1683,9 +1760,9 @@ function CreateProfilePageContent() { {Object.entries(card.identity.links).some(([, v]) => v) && (
{Object.entries(card.identity.links).filter(([, v]) => v).map(([key, url]) => ( - + - {shortenUrl(url)} + {shortenUrl(url)} @@ -1702,8 +1779,8 @@ function CreateProfilePageContent() {
Profile URL — optional, set your custom slug
-
-
+
+
zynd.ai/p/
onCustomHandleChange(e.target.value)} placeholder="your-handle" - className="zc-field" + className="zc-field zc-handle-input" style={{ width: "100%", padding: "13px 15px 13px 98px", fontSize: "15px", fontFamily: MONO, @@ -1720,7 +1797,7 @@ function CreateProfilePageContent() { }} /> {customHandle.length >= 2 && ( - + {handleChecking ? "checking…" : handleAvailable === false ? "taken" : handleAvailable === true ? "available ✓" : ""} )} @@ -1801,8 +1878,8 @@ function CreateProfilePageContent() { const key = sampleKey(i); const off = excluded.has(key); return ( -
- +
+ {w.platform} @@ -1823,7 +1900,7 @@ function CreateProfilePageContent() { {/* ── Add more sources ── */} {!editHandle && (
-
+
Add more sources re-extract with additional profiles
@@ -1831,17 +1908,17 @@ function CreateProfilePageContent() { {addMoreUrls.length > 0 && (
{addMoreUrls.map(url => ( -
- {shortenUrl(url)} -
))}
)} -
-
+
+
- @@ -1881,7 +1958,7 @@ function CreateProfilePageContent() {
{(addMoreUrls.length > 0 || addMoreResume) && ( - @@ -1907,19 +1984,19 @@ function CreateProfilePageContent() {
{/* ── tagline footer row — inside shell, always visible ── */} -
-
+
+

Your work,
discoverable by AI.

-
+
{[ { num: "01", label: "Add your profiles", desc: "GitHub · LinkedIn · X · any URL" }, { num: "02", label: "We scrape the public web", desc: "No passwords, no permissions" }, { num: "03", label: "You review and approve", desc: "Edit every line before it goes live" }, ].map(({ num, label, desc }, i) => ( -
0 ? "24px" : "0", borderLeft: i > 0 ? `1px solid ${T.border}` : "none", marginLeft: i > 0 ? "24px" : "0", display: "flex", flexDirection: "column", gap: "5px" }}> +
0 ? "24px" : "0", borderLeft: i > 0 ? `1px solid ${T.border}` : "none", marginLeft: i > 0 ? "24px" : "0", display: "flex", flexDirection: "column", gap: "5px" }}> {num} {label} {desc} diff --git a/apps/cards-web/src/app/directory/page.tsx b/apps/cards-web/src/app/(site)/directory/page.tsx similarity index 100% rename from apps/cards-web/src/app/directory/page.tsx rename to apps/cards-web/src/app/(site)/directory/page.tsx diff --git a/apps/cards-web/src/app/find/page.tsx b/apps/cards-web/src/app/(site)/find/page.tsx similarity index 100% rename from apps/cards-web/src/app/find/page.tsx rename to apps/cards-web/src/app/(site)/find/page.tsx diff --git a/apps/cards-web/src/app/for-ai/page.tsx b/apps/cards-web/src/app/(site)/for-ai/page.tsx similarity index 100% rename from apps/cards-web/src/app/for-ai/page.tsx rename to apps/cards-web/src/app/(site)/for-ai/page.tsx diff --git a/apps/cards-web/src/app/layout.tsx b/apps/cards-web/src/app/(site)/layout.tsx similarity index 99% rename from apps/cards-web/src/app/layout.tsx rename to apps/cards-web/src/app/(site)/layout.tsx index d30ef36..8ed997c 100644 --- a/apps/cards-web/src/app/layout.tsx +++ b/apps/cards-web/src/app/(site)/layout.tsx @@ -2,7 +2,7 @@ import type { Metadata } from "next"; import Script from "next/script"; import { Providers } from "@/components/providers"; import { getServerAuth } from "@/lib/auth/server"; -import "./globals.css"; +import "../globals.css"; import "@/zynd-ui.css"; const SITE_URL = "https://cards.zynd.ai"; diff --git a/apps/cards-web/src/app/p/[handle]/auto-scroll.tsx b/apps/cards-web/src/app/(site)/p/[handle]/auto-scroll.tsx similarity index 100% rename from apps/cards-web/src/app/p/[handle]/auto-scroll.tsx rename to apps/cards-web/src/app/(site)/p/[handle]/auto-scroll.tsx diff --git a/apps/cards-web/src/app/p/[handle]/contribution-heatmap.tsx b/apps/cards-web/src/app/(site)/p/[handle]/contribution-heatmap.tsx similarity index 100% rename from apps/cards-web/src/app/p/[handle]/contribution-heatmap.tsx rename to apps/cards-web/src/app/(site)/p/[handle]/contribution-heatmap.tsx diff --git a/apps/cards-web/src/app/p/[handle]/count-up.tsx b/apps/cards-web/src/app/(site)/p/[handle]/count-up.tsx similarity index 100% rename from apps/cards-web/src/app/p/[handle]/count-up.tsx rename to apps/cards-web/src/app/(site)/p/[handle]/count-up.tsx diff --git a/apps/cards-web/src/app/p/[handle]/data.json/route.ts b/apps/cards-web/src/app/(site)/p/[handle]/data.json/route.ts similarity index 100% rename from apps/cards-web/src/app/p/[handle]/data.json/route.ts rename to apps/cards-web/src/app/(site)/p/[handle]/data.json/route.ts diff --git a/apps/cards-web/src/app/p/[handle]/dossier-shell.tsx b/apps/cards-web/src/app/(site)/p/[handle]/dossier-shell.tsx similarity index 100% rename from apps/cards-web/src/app/p/[handle]/dossier-shell.tsx rename to apps/cards-web/src/app/(site)/p/[handle]/dossier-shell.tsx diff --git a/apps/cards-web/src/app/p/[handle]/edit-card-button.tsx b/apps/cards-web/src/app/(site)/p/[handle]/edit-card-button.tsx similarity index 100% rename from apps/cards-web/src/app/p/[handle]/edit-card-button.tsx rename to apps/cards-web/src/app/(site)/p/[handle]/edit-card-button.tsx diff --git a/apps/cards-web/src/app/p/[handle]/edit/edit-client.tsx b/apps/cards-web/src/app/(site)/p/[handle]/edit/edit-client.tsx similarity index 98% rename from apps/cards-web/src/app/p/[handle]/edit/edit-client.tsx rename to apps/cards-web/src/app/(site)/p/[handle]/edit/edit-client.tsx index 8c568ca..d7b6080 100644 --- a/apps/cards-web/src/app/p/[handle]/edit/edit-client.tsx +++ b/apps/cards-web/src/app/(site)/p/[handle]/edit/edit-client.tsx @@ -265,6 +265,7 @@ export function EditProfileClient({ initialCard, handle, token }: Props) { const [newSkillName, setNewSkillName] = useState(""); const [newSkillLevel, setNewSkillLevel] = useState("intermediate"); const [calInput, setCalInput] = useState(draft.calendly_url ?? ""); + const [gcalInput, setGcalInput] = useState(draft.google_calendar_url ?? ""); function addSkill() { const name = newSkillName.trim(); @@ -294,6 +295,7 @@ export function EditProfileClient({ initialCard, handle, token }: Props) { love_talking_about: oTalking, skills: skillList, calendly_url: calInput.trim() || null, + google_calendar_url: gcalInput.trim() || null, }; } @@ -813,10 +815,14 @@ export function EditProfileClient({ initialCard, handle, token }: Props) {

Your public handle and a scheduling link, if you take meetings.

- + setCalInput(e.target.value)} className="ei" placeholder="https://calendly.com/…" />
+ + setGcalInput(e.target.value)} className="ei" placeholder="https://calendar.app.google/…" /> +
+
zynd.ai/p/ diff --git a/apps/cards-web/src/app/p/[handle]/edit/page.tsx b/apps/cards-web/src/app/(site)/p/[handle]/edit/page.tsx similarity index 100% rename from apps/cards-web/src/app/p/[handle]/edit/page.tsx rename to apps/cards-web/src/app/(site)/p/[handle]/edit/page.tsx diff --git a/apps/cards-web/src/app/p/[handle]/hero-agent-bar.tsx b/apps/cards-web/src/app/(site)/p/[handle]/hero-agent-bar.tsx similarity index 100% rename from apps/cards-web/src/app/p/[handle]/hero-agent-bar.tsx rename to apps/cards-web/src/app/(site)/p/[handle]/hero-agent-bar.tsx diff --git a/apps/cards-web/src/app/p/[handle]/page.tsx b/apps/cards-web/src/app/(site)/p/[handle]/page.tsx similarity index 98% rename from apps/cards-web/src/app/p/[handle]/page.tsx rename to apps/cards-web/src/app/(site)/p/[handle]/page.tsx index ccd7a1c..fa80a67 100644 --- a/apps/cards-web/src/app/p/[handle]/page.tsx +++ b/apps/cards-web/src/app/(site)/p/[handle]/page.tsx @@ -18,7 +18,7 @@ import { SkillMatrix } from "./skill-matrix"; import { ShareQrGroup, CopyPermalinkIcon } from "./share-controls"; import type { ResumeData } from "./resume-pdf"; import { EditCardButton } from "./edit-card-button"; -import { ProfileSignIn, ClaimIfCreator } from "./profile-auth-actions"; +import { ProfileSignIn, ClaimCardButton } from "./profile-auth-actions"; import { CountUp } from "./count-up"; import { AutoScroll } from "./auto-scroll"; import { ContributionHeatmap } from "./contribution-heatmap"; @@ -400,7 +400,10 @@ export default async function PersonPage({ params }: PageProps) { const ghLogin = usernameFromUrl(identity.links?.github); const ghExtras = ghLogin ? await fetchGithubExtras(ghLogin) : null; const xUrl = safeUrl(identity.links?.x); - const calendlyUrl = safeUrl(card.calendly_url); + const bookingLinks = [ + { label: "Calendly", url: safeUrl(card.calendly_url) }, + { label: "Google Calendar", url: safeUrl(card.google_calendar_url) }, + ].filter((l): l is { label: string; url: string } => !!l.url); const memoryFacts = (card.zynd_memory ?? []) as Array>; @@ -499,7 +502,7 @@ export default async function PersonPage({ params }: PageProps) { const xAvatar = safeUrl(card.x_stats?.avatar) ?? avatarUrl; const xImpressions = v.x.impressions != null && String(v.x.impressions).trim() !== "—" ? v.x.impressions : null; const showMemory = memoryTotal > 0; - const socialSlots = [showLinkedin, showX, showMemory, !!calendlyUrl].filter(Boolean).length; + const socialSlots = [showLinkedin, showX, showMemory, bookingLinks.length > 0].filter(Boolean).length; const weekLabels = ["S", "M", "T", "W", "T", "F", "S"]; const todayIdx = new Date().getDay(); @@ -665,7 +668,7 @@ export default async function PersonPage({ params }: PageProps) { {isOwner && } {!isSignedIn && } - {isSignedIn && !isOwner && } + {isSignedIn && !isOwner && }
@@ -1016,8 +1019,8 @@ export default async function PersonPage({ params }: PageProps) {
)} - {/* ── BOOK A CALL (only when calendlyUrl exists) ── */} - {calendlyUrl && ( + {/* ── BOOK A CALL (only when a Calendly or Google Calendar link exists) ── */} + {bookingLinks.length > 0 && (
- - Book intro call - → - + {/* One link: the usual CTA. Both: one button per provider. */} +
)} diff --git a/apps/cards-web/src/app/(site)/p/[handle]/profile-auth-actions.tsx b/apps/cards-web/src/app/(site)/p/[handle]/profile-auth-actions.tsx new file mode 100644 index 0000000..37b03e2 --- /dev/null +++ b/apps/cards-web/src/app/(site)/p/[handle]/profile-auth-actions.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { useRouter } from "next/navigation"; +import { createClient } from "@/lib/supabase/client"; +import { setAuthNext } from "@/lib/auth/next-cookie"; +import { getMyCard, updateCard, type AgentProfileCard } from "@/lib/cards"; +import { hasClaimToken, markClaimIntent, subscribeClaimTokens, takeClaimIntent } from "@/lib/claim-tokens"; + +const PILL_CLASS = + "inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-full font-mono text-[12px]! font-semibold cursor-pointer hover:opacity-90 transition-opacity disabled:opacity-60"; +const PILL_STYLE = { background: "#7B72E9", color: "#fff", border: "none" }; + +/** + * Only the browser that published a card anonymously holds its one-time claim + * token, so that's what makes a visitor "the creator". False on the server and + * during hydration; the real answer comes from localStorage right after. + */ +function useCanClaim(handle: string): boolean { + return useSyncExternalStore(subscribeClaimTokens, () => hasClaimToken(handle), () => false); +} + +/** Signed-out header action: "Claim this card" for its creator, "Sign In" for everyone else. */ +export function ProfileSignIn({ handle }: { handle: string }) { + const canClaim = useCanClaim(handle); + + function signIn() { + if (canClaim) markClaimIntent(handle); + setAuthNext(`/p/${encodeURIComponent(handle)}`, "card"); + createClient().auth.signInWithOAuth({ + provider: "linkedin_oidc", + options: { redirectTo: `${window.location.origin}/auth/callback` }, + }); + } + + return ( + + ); +} + +/** + * Signed-in, not-yet-owner header action. Shown only to the card's creator; + * claims on click, or by itself right after a sign-in that started from + * "Claim this card". On success the server re-renders the header with Edit. + */ +export function ClaimCardButton({ handle, card }: { handle: string; card: AgentProfileCard }) { + const router = useRouter(); + const canClaim = useCanClaim(handle); + const [status, setStatus] = useState<"idle" | "claiming" | "failed" | "has-card">("idle"); + const autoClaimed = useRef(false); + + const claim = useCallback(async () => { + setStatus("claiming"); + try { + const { data: { session } } = await createClient().auth.getSession(); + const token = session?.access_token; + if (!token) throw new Error("no session"); + // One card per account: an account that already owns a card can't take this one too. + const mine = await getMyCard(token); + if (mine?.handle) { + setStatus("has-card"); + return; + } + if (!(await updateCard(handle, card, token))) throw new Error("claim refused"); + router.refresh(); + } catch (err) { + console.error("[ClaimCardButton] failed:", err); + setStatus("failed"); + } + }, [handle, card, router]); + + useEffect(() => { + if (!canClaim || autoClaimed.current || !takeClaimIntent(handle)) return; + autoClaimed.current = true; + claim(); + }, [canClaim, handle, claim]); + + if (!canClaim) return null; + if (status === "has-card") { + return ( + + Your account already has a card + + ); + } + return ( + + ); +} diff --git a/apps/cards-web/src/app/p/[handle]/projects-card.tsx b/apps/cards-web/src/app/(site)/p/[handle]/projects-card.tsx similarity index 100% rename from apps/cards-web/src/app/p/[handle]/projects-card.tsx rename to apps/cards-web/src/app/(site)/p/[handle]/projects-card.tsx diff --git a/apps/cards-web/src/app/p/[handle]/resume-pdf.ts b/apps/cards-web/src/app/(site)/p/[handle]/resume-pdf.ts similarity index 88% rename from apps/cards-web/src/app/p/[handle]/resume-pdf.ts rename to apps/cards-web/src/app/(site)/p/[handle]/resume-pdf.ts index 36bb9bd..011c3d2 100644 --- a/apps/cards-web/src/app/p/[handle]/resume-pdf.ts +++ b/apps/cards-web/src/app/(site)/p/[handle]/resume-pdf.ts @@ -114,6 +114,11 @@ function prettyUrl(url: string): string { return pdfSafe(url.replace(/^https?:\/\//, "").replace(/^www\./, "").replace(/\/+$/, "")); } +/** Only real web URLs become PDF link annotations. */ +function linkTarget(url: string | null | undefined): string | null { + return url && /^https?:\/\//i.test(url) ? url : null; +} + function dateRange(job: ResumeWorkItem): string { const start = clean(job.start_date); const end = clean(job.end_date); @@ -207,24 +212,30 @@ export async function buildResumePdf(data: ResumeData): Promise { doc.text(meta.join(" · "), M, y); } - // Wrapped by whole links, so a separator never dangles at a line end. - const linkParts = data.links.map((l) => prettyUrl(l.url)).filter(Boolean); + // Each link is its own clickable annotation. Wrapped by whole links, so a + // separator never dangles at a line end. + const linkParts = data.links + .map((l) => ({ text: prettyUrl(l.url), url: linkTarget(l.url) })) + .filter((l) => l.text); if (linkParts.length) { y += 13; font("normal", 8.5, SLATE); const SEP = " · "; - let line = ""; + const sepW = doc.getTextWidth(SEP); + let x = M; for (const part of linkParts) { - const next = line ? line + SEP + part : part; - if (line && doc.getTextWidth(next) > CW) { - doc.text(line, M, y); + const w = doc.getTextWidth(part.text); + if (x > M && x + sepW + w > M + CW) { y += 11; - line = part; - } else { - line = next; + x = M; + } else if (x > M) { + doc.text(SEP, x, y); + x += sepW; } + if (part.url) doc.textWithLink(part.text, x, y, { url: part.url }); + else doc.text(part.text, x, y); + x += w; } - if (line) doc.text(line, M, y); } y += 12; @@ -358,7 +369,14 @@ export async function buildResumePdf(data: ResumeData): Promise { if (tail) { ensure(12); font("normal", 8, MUTED); - doc.text(doc.splitTextToSize(tail, CW)[0], M, y); + const shown = doc.splitTextToSize(tail, CW)[0] as string; + doc.text(shown, M, y); + // The URL closes the line; link it when it wasn't cut off by the wrap. + const target = linkTarget(proj.url); + if (target && url && shown === tail) { + const urlW = doc.getTextWidth(url); + doc.link(M + doc.getTextWidth(tail) - urlW, y - 8, urlW, 10.5, { url: target }); + } y += 12; } @@ -375,7 +393,10 @@ export async function buildResumePdf(data: ResumeData): Promise { doc.setLineWidth(0.6); doc.line(M, PH - 44, M + CW, PH - 44); font("normal", 7.8, MUTED); - doc.text(`Live profile · ${prettyUrl(data.profileUrl)}`, M, PH - 30); + const profileLink = linkTarget(data.profileUrl); + const footer = `Live profile · ${prettyUrl(data.profileUrl)}`; + if (profileLink) doc.textWithLink(footer, M, PH - 30, { url: profileLink }); + else doc.text(footer, M, PH - 30); doc.text(`${p} / ${pages}`, M + CW, PH - 30, { align: "right" }); } diff --git a/apps/cards-web/src/app/p/[handle]/resume-picker.tsx b/apps/cards-web/src/app/(site)/p/[handle]/resume-picker.tsx similarity index 100% rename from apps/cards-web/src/app/p/[handle]/resume-picker.tsx rename to apps/cards-web/src/app/(site)/p/[handle]/resume-picker.tsx diff --git a/apps/cards-web/src/app/p/[handle]/share-controls.tsx b/apps/cards-web/src/app/(site)/p/[handle]/share-controls.tsx similarity index 100% rename from apps/cards-web/src/app/p/[handle]/share-controls.tsx rename to apps/cards-web/src/app/(site)/p/[handle]/share-controls.tsx diff --git a/apps/cards-web/src/app/p/[handle]/skill-icon.tsx b/apps/cards-web/src/app/(site)/p/[handle]/skill-icon.tsx similarity index 100% rename from apps/cards-web/src/app/p/[handle]/skill-icon.tsx rename to apps/cards-web/src/app/(site)/p/[handle]/skill-icon.tsx diff --git a/apps/cards-web/src/app/p/[handle]/skill-matrix.tsx b/apps/cards-web/src/app/(site)/p/[handle]/skill-matrix.tsx similarity index 100% rename from apps/cards-web/src/app/p/[handle]/skill-matrix.tsx rename to apps/cards-web/src/app/(site)/p/[handle]/skill-matrix.tsx diff --git a/apps/cards-web/src/app/p/[handle]/social-quote-reel.tsx b/apps/cards-web/src/app/(site)/p/[handle]/social-quote-reel.tsx similarity index 100% rename from apps/cards-web/src/app/p/[handle]/social-quote-reel.tsx rename to apps/cards-web/src/app/(site)/p/[handle]/social-quote-reel.tsx diff --git a/apps/cards-web/src/app/p/[handle]/work-experience-card.tsx b/apps/cards-web/src/app/(site)/p/[handle]/work-experience-card.tsx similarity index 100% rename from apps/cards-web/src/app/p/[handle]/work-experience-card.tsx rename to apps/cards-web/src/app/(site)/p/[handle]/work-experience-card.tsx diff --git a/apps/cards-web/src/app/profile/[id]/edit-profile-button.tsx b/apps/cards-web/src/app/(site)/profile/[id]/edit-profile-button.tsx similarity index 100% rename from apps/cards-web/src/app/profile/[id]/edit-profile-button.tsx rename to apps/cards-web/src/app/(site)/profile/[id]/edit-profile-button.tsx diff --git a/apps/cards-web/src/app/profile/[id]/page.tsx b/apps/cards-web/src/app/(site)/profile/[id]/page.tsx similarity index 100% rename from apps/cards-web/src/app/profile/[id]/page.tsx rename to apps/cards-web/src/app/(site)/profile/[id]/page.tsx diff --git a/apps/cards-web/src/app/search/page.tsx b/apps/cards-web/src/app/(site)/search/page.tsx similarity index 100% rename from apps/cards-web/src/app/search/page.tsx rename to apps/cards-web/src/app/(site)/search/page.tsx diff --git a/apps/cards-web/src/app/tag/[skill]/page.tsx b/apps/cards-web/src/app/(site)/tag/[skill]/page.tsx similarity index 100% rename from apps/cards-web/src/app/tag/[skill]/page.tsx rename to apps/cards-web/src/app/(site)/tag/[skill]/page.tsx diff --git a/apps/cards-web/src/app/p/[handle]/profile-auth-actions.tsx b/apps/cards-web/src/app/p/[handle]/profile-auth-actions.tsx deleted file mode 100644 index 55a9b9c..0000000 --- a/apps/cards-web/src/app/p/[handle]/profile-auth-actions.tsx +++ /dev/null @@ -1,58 +0,0 @@ -"use client"; - -import { useEffect, useRef } from "react"; -import { createClient } from "@/lib/supabase/client"; -import { setAuthNext } from "@/lib/auth/next-cookie"; -import { getMyCard, updateCard, type AgentProfileCard } from "@/lib/cards"; - -export function ProfileSignIn({ handle }: { handle: string }) { - function signIn() { - setAuthNext(`/p/${encodeURIComponent(handle)}`, "card"); - createClient().auth.signInWithOAuth({ - provider: "linkedin_oidc", - options: { redirectTo: `${window.location.origin}/auth/callback` }, - }); - } - - return ( - - ); -} - -export function ClaimIfCreator({ handle, card }: { handle: string; card: AgentProfileCard }) { - const ran = useRef(false); - - useEffect(() => { - if (ran.current) return; - ran.current = true; - let cancelled = false; - - async function claim() { - try { - const stored: string[] = JSON.parse(localStorage.getItem("zynd_my_handles") || "[]"); - if (!stored.includes(handle)) return; - const { data: { session } } = await createClient().auth.getSession(); - const token = session?.access_token; - if (!token) return; - const mine = await getMyCard(token); - if (mine?.handle) return; - if (cancelled) return; - await updateCard(handle, card, token); - } catch (err) { - console.error("[ClaimIfCreator] failed:", err); - } - } - - claim(); - return () => { cancelled = true; }; - }, [handle, card]); - - return null; -} diff --git a/apps/cards-web/src/app/p/[handle]/unclaimed-card-actions.tsx b/apps/cards-web/src/app/p/[handle]/unclaimed-card-actions.tsx deleted file mode 100644 index a86ee57..0000000 --- a/apps/cards-web/src/app/p/[handle]/unclaimed-card-actions.tsx +++ /dev/null @@ -1,91 +0,0 @@ -"use client"; -import { useEffect, useState } from "react"; -import { useAuth } from "@/hooks/useAuth"; -import { createClient } from "@/lib/supabase/client"; -import { claimHeaders, forgetClaimToken } from "@/lib/claim-tokens"; - -const STORAGE_KEY = "zynd_my_handles"; - -export function UnclaimedCardActions({ handle }: { handle: string }) { - const { authenticated, ready } = useAuth(); - const [isCreator, setIsCreator] = useState(false); - const [claiming, setClaiming] = useState(false); - const [claimed, setClaimed] = useState(false); - - useEffect(() => { - try { - const stored: string[] = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]"); - setIsCreator(stored.includes(handle)); - } catch { /* non-fatal */ } - }, [handle]); - - // Once user signs in after creating, auto-claim (PATCH card with their email) - // then remove handle from localStorage since it's now officially owned. - useEffect(() => { - if (!authenticated || !isCreator || claimed) return; - const supabase = createClient(); - supabase.auth.getSession().then(async ({ data }) => { - const token = data.session?.access_token; - if (!token) return; - try { - const res = await fetch( - `${process.env.NEXT_PUBLIC_API_URL || "https://api.zynd.ai"}/cards/by-handle/${encodeURIComponent(handle)}`, - { - method: "PATCH", - headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, ...claimHeaders(handle) }, - body: JSON.stringify({}), - } - ); - if (res.ok) { - forgetClaimToken(handle); - setClaimed(true); - try { - const stored: string[] = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]"); - localStorage.setItem(STORAGE_KEY, JSON.stringify(stored.filter(h => h !== handle))); - } catch { /* non-fatal */ } - } - } catch { /* non-fatal */ } - }); - }, [authenticated, isCreator, claimed, handle]); - - async function claimCard() { - setClaiming(true); - // Store intended destination, then trigger LinkedIn OAuth - document.cookie = `zynd_next=/p/${handle}; path=/; samesite=lax`; - const loginRedirect = `${window.location.origin}/auth/callback`; - await createClient().auth.signInWithOAuth({ - provider: "linkedin_oidc", - options: { redirectTo: loginRedirect }, - }); - } - - if (!ready || (!isCreator && !claimed)) return null; - if (claimed) { - return ( - - ✓ Card claimed - - ); - } - - return ( -
- - Edit my card → - - {!authenticated && ( - - )} -
- ); -} diff --git a/apps/cards-web/src/app/page.tsx b/apps/cards-web/src/app/page.tsx deleted file mode 100644 index 0ca7be6..0000000 --- a/apps/cards-web/src/app/page.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import Link from "next/link"; -import { Navbar } from "@/components/Navbar"; - -export default function HomePage() { - return ( - <> - -
-

- Your Zynd Card — a living profile AI agents can find and talk to. -

-

- One page that pulls together your work, your skills, and your writing — - searchable by other people and by the AI agents acting on their behalf. -

-
- - Create your card - - - Browse the directory - -
-
- - ); -} diff --git a/apps/cards-web/src/lib/auth/next-cookie.ts b/apps/cards-web/src/lib/auth/next-cookie.ts index c3c6a46..b831b1f 100644 --- a/apps/cards-web/src/lib/auth/next-cookie.ts +++ b/apps/cards-web/src/lib/auth/next-cookie.ts @@ -18,7 +18,7 @@ export function safeNextPath(raw: string | null | undefined): string | null { } export function setAuthNext(path: string, intent?: "card"): void { - const next = isSafePath(path) ? path : "/agent-card"; + const next = isSafePath(path) ? path : "/"; document.cookie = `${NEXT_COOKIE}=${encodeURIComponent(next)}; path=/; samesite=lax`; if (intent === "card") { document.cookie = `${INTENT_COOKIE}=card; path=/; samesite=lax`; diff --git a/apps/cards-web/src/lib/cards.ts b/apps/cards-web/src/lib/cards.ts index 86a946c..40575cb 100644 --- a/apps/cards-web/src/lib/cards.ts +++ b/apps/cards-web/src/lib/cards.ts @@ -113,6 +113,8 @@ export interface AgentProfileCard { contribution_stats?: ContributionStats | null; endorsement?: Endorsement | null; calendly_url?: string | null; + /** Google Calendar appointment-schedule booking page. */ + google_calendar_url?: string | null; /** Structured LinkedIn work history extracted at scrape time. */ work_experience?: Array<{ title: string; diff --git a/apps/cards-web/src/lib/claim-tokens.ts b/apps/cards-web/src/lib/claim-tokens.ts index a8f1123..69e88c5 100644 --- a/apps/cards-web/src/lib/claim-tokens.ts +++ b/apps/cards-web/src/lib/claim-tokens.ts @@ -38,6 +38,43 @@ export function forgetClaimToken(handle: string): void { } } +/** Did this browser publish `handle` anonymously, and so can still claim it? */ +export function hasClaimToken(handle: string): boolean { + return typeof read()[handle] === "string"; +} + +/** useSyncExternalStore subscription: another tab publishing or claiming changes the answer. */ +export function subscribeClaimTokens(onChange: () => void): () => void { + const onStorage = (e: StorageEvent) => { + if (e.key === KEY || e.key === null) onChange(); + }; + window.addEventListener("storage", onStorage); + return () => window.removeEventListener("storage", onStorage); +} + +// Survives the LinkedIn OAuth round-trip (same tab, same origin) so the claim +// can finish on return without a second click. +const INTENT_KEY = "zynd_claim_intent"; + +export function markClaimIntent(handle: string): void { + try { + sessionStorage.setItem(INTENT_KEY, handle); + } catch { + /* storage unavailable — the claim button still works, it just needs a click */ + } +} + +/** True once, right after a sign-in started from "Claim this card" on `handle`. */ +export function takeClaimIntent(handle: string): boolean { + try { + if (sessionStorage.getItem(INTENT_KEY) !== handle) return false; + sessionStorage.removeItem(INTENT_KEY); + return true; + } catch { + return false; + } +} + /** `{ "X-Claim-Token": … }` when this browser published `handle` anonymously. */ export function claimHeaders(handle: string): Record { const token = read()[handle]; diff --git a/infra/single-box/Caddyfile b/infra/single-box/Caddyfile index f14225b..afd8865 100644 --- a/infra/single-box/Caddyfile +++ b/infra/single-box/Caddyfile @@ -1,13 +1,134 @@ -# Host Caddy for the single-box "ports mode" deployment (install to /etc/caddy/Caddyfile). -# Plain HTTP on a port: no domain, so no automatic HTTPS. -# -# persona: persona-web and persona-api must share an origin, same rule as the -# live persona box: /api/* -> backend, everything else -> the Next.js app. +# Host Caddy for the single-box deployment (install to /etc/caddy/Caddyfile). +# Caddy gets and renews the Let's Encrypt certificates itself, but only once +# each hostname's A record points at this box and ports 80/443 are open. +# After pointing DNS here, `sudo systemctl reload caddy` retries issuance +# immediately instead of waiting for Caddy's backoff. + { - auto_https off + # Let's Encrypt only. Ubuntu's Caddy 2.6.2 tries ZeroSSL first, and ZeroSSL's + # legacy EAB registration endpoint is gone (HTTP 422 caddy_legacy_user_removed), + # which makes every issuance attempt error out. Setting acme_ca drops the + # ZeroSSL issuer. Not needed on a current Caddy. + acme_ca https://acme-v02.api.letsencrypt.org/directory +} + +# persona-web. /api/* also goes to persona-api on this host, same rule as the +# live persona box: the OAuth callbacks and A2A URLs registered with providers +# and the registry are https://persona.zynd.ai/api/... +persona.zynd.ai { + handle /api/* { + reverse_proxy 127.0.0.1:8000 + } + handle { + reverse_proxy 127.0.0.1:3001 + } +} + +# persona-api on its own host (what persona-web's NEXT_PUBLIC_API_URL points at). +persona.api.zynd.ai { + reverse_proxy 127.0.0.1:8000 +} + +# cards-web +cards.zynd.ai { + reverse_proxy 127.0.0.1:3002 +} + +# cards-api on its own host (what cards-web's NEXT_PUBLIC_API_URL points at). +cards.api.zynd.ai { + reverse_proxy 127.0.0.1:8002 +} + +# memory API, plus the legacy paths clients already call on api.zynd.ai +# (same routing as infra/api-box/Caddyfile). +api.zynd.ai { + # Remote MCP endpoint (streamable-HTTP) for Claude/Cursor/any MCP client. + handle /mcp* { + reverse_proxy 127.0.0.1:8090 + } + # OAuth protected-resource metadata (RFC 9728), served by FastMCP. + handle /.well-known/oauth-protected-resource* { + reverse_proxy 127.0.0.1:8090 + } + # Memory-layer service-to-service endpoint (called by cards). + # MUST stay above /v1*: that prefix otherwise routes to cards. + handle /v1/service* { + reverse_proxy 127.0.0.1:8001 + } + # Legacy cards paths on the memory host. + handle /cards* { + reverse_proxy 127.0.0.1:8002 + } + handle /ask* { + reverse_proxy 127.0.0.1:8002 + } + handle /onboard* { + reverse_proxy 127.0.0.1:8002 + } + handle /v1* { + reverse_proxy 127.0.0.1:8002 + } + handle { + reverse_proxy 127.0.0.1:8001 + } +} + +# ── DEV instance (branch `dev`; see docker-compose.dev.yml, ecosystem.dev.config.js) ── +# Same shape as prod above, on the dev.* names and the dev ports. Everything is +# localhost-only behind these HTTPS names; no extra public ports. + +dev.persona.zynd.ai { + handle /api/* { + reverse_proxy 127.0.0.1:8100 + } + handle { + reverse_proxy 127.0.0.1:3101 + } +} + +dev.persona.api.zynd.ai { + reverse_proxy 127.0.0.1:8100 +} + +dev.cards.zynd.ai { + reverse_proxy 127.0.0.1:3102 +} + +dev.cards.api.zynd.ai { + reverse_proxy 127.0.0.1:8102 +} + +dev.api.zynd.ai { + handle /mcp* { + reverse_proxy 127.0.0.1:8190 + } + handle /.well-known/oauth-protected-resource* { + reverse_proxy 127.0.0.1:8190 + } + handle /v1/service* { + reverse_proxy 127.0.0.1:8101 + } + handle /cards* { + reverse_proxy 127.0.0.1:8102 + } + handle /ask* { + reverse_proxy 127.0.0.1:8102 + } + handle /onboard* { + reverse_proxy 127.0.0.1:8102 + } + handle /v1* { + reverse_proxy 127.0.0.1:8102 + } + handle { + reverse_proxy 127.0.0.1:8101 + } } -:3000 { +# Plain-HTTP IP access to persona (persona-web and persona-api on one origin). +# The web apps are built against the domains above, so this is only useful for +# poking at persona-api directly (http://:3000/api/...). +http://:3000 { handle /api/* { reverse_proxy 127.0.0.1:8000 } diff --git a/infra/single-box/README.md b/infra/single-box/README.md index 5d8f288..2bbb246 100644 --- a/infra/single-box/README.md +++ b/infra/single-box/README.md @@ -1,20 +1,59 @@ -# infra/single-box: whole monorepo on one box, reached by IP:port +# infra/single-box: whole monorepo on one box The repo's other `infra/` folders describe two boxes with domains (`persona-box` = pm2, `api-box` = compose + Caddy). This folder runs **every** -service on one Ubuntu box with no domains, over plain HTTP on ports. +service on one Ubuntu box: host Caddy serves five HTTPS domains, and the +backends are also reachable directly by IP:port. First used on `169.58.17.193` (2026-09-29). -| Port | What | Runs as | +| Domain | What | Behind Caddy | |---|---|---| -| 3000 | persona-web, and persona-api under `/api/*` | host Caddy -> pm2 `web` (:3001) + pm2 `api` (:8000) | -| 3002 | cards-web | pm2 `cards-web` | -| 8001 | memory API | Docker (`api`) | -| 8090 | memory MCP (`/mcp`) | Docker (`mcp`) | -| 8002 | cards-api | Docker (`cards`) | +| `persona.zynd.ai` | persona-web; `/api/*` -> persona-api (same rule as the live persona box) | pm2 `web` :3001, pm2 `api` :8000 | +| `persona.api.zynd.ai` | persona-api | pm2 `api` :8000 | +| `cards.zynd.ai` | cards-web | pm2 `cards-web` :3002 | +| `cards.api.zynd.ai` | cards-api | Docker `cards` :8002 | +| `api.zynd.ai` | memory API; `/mcp*` -> MCP; legacy `/cards* /ask* /onboard* /v1*` -> cards-api (as in `infra/api-box/Caddyfile`) | Docker `api` :8001, `mcp` :8090 | -Memory's Postgres (pgvector) and Redis are Docker-internal only. The worker has -no port. persona-api's own :8000 stays on localhost. +Direct ports (plain HTTP, open in ufw): 8001 memory, 8090 memory MCP, 8002 +cards-api, 3002 cards-web, 3000 persona (`/api/*` -> backend, rest -> web). +Memory's Postgres (pgvector) and Redis are Docker-internal only. The worker +has no port. persona-api's own :8000 stays on localhost. + +Certificates: Caddy issues them itself once a name's A record points at the +box and ports 80/443 are open. Until then the HTTPS names don't work; after +pointing DNS, `sudo systemctl reload caddy` retries issuance immediately. + +## Two environments: `main` -> prod, `dev` -> dev + +The box runs both, from two checkouts, sharing the same databases for now: + +| | prod | dev | +|---|---|---| +| branch / checkout | `main` / `/home/ubuntu/zynd-platform` | `dev` / `/home/ubuntu/zynd-platform-dev` | +| persona-web / persona-api | `persona.zynd.ai` / `persona.api.zynd.ai` | `dev.persona.zynd.ai` / `dev.persona.api.zynd.ai` | +| cards-web / cards-api | `cards.zynd.ai` / `cards.api.zynd.ai` | `dev.cards.zynd.ai` / `dev.cards.api.zynd.ai` | +| memory (+ MCP at `/mcp`) | `api.zynd.ai` | `dev.api.zynd.ai` | +| pm2 apps (`ecosystem*.config.js`) | `api` :8000, `web` :3001, `cards-web` :3002 | `api-dev` :8100, `web-dev` :3101, `cards-web-dev` :3102 | +| containers (compose project) | `zynd`: postgres, redis, api :8001, worker, mcp :8090, cards :8002 | `zynd-dev`: api-dev :8101, mcp-dev :8190, cards-dev :8102 | + +Dev's ports are all 127.0.0.1, served only through the `dev.*` HTTPS names +(no extra public ports). Same DB for now means: + +- **aafo (Supabase)** is shared by both, as everywhere else in this repo. +- **memory's Postgres and Redis** are prod's: dev's containers join prod's + Docker network (`zynd_default`). Dev-branch code runs against the same rows. +- **No dev memory worker**: its nightly cron jobs (decay, resolution, + recompute, orphan cleanup) would run twice on the shared database. Jobs the + dev API enqueues are consumed by prod's worker from the shared Redis. +- **persona-api runs its background loops in both** (heartbeats, brief watcher, + syncs), same as the old prod-and-dev pair on the persona box. Neither has a + Telegram token, and `ZYND_WEBHOOK_BASE_URL` and the OAuth redirect URIs stay + on the prod names in both (persona-api `CLAUDE.md`). +- **Supabase Auth redirect URLs** must list every origin that signs in: + `https://dev.persona.zynd.ai/**` and `https://dev.cards.zynd.ai/**` for dev. + +The dev env files are copies of prod's with the `dev.*` URLs, generated on the +box; splitting the databases later means pointing dev's env at new ones. ## Bring-up @@ -37,7 +76,7 @@ sudo install -m644 infra/single-box/Caddyfile /etc/caddy/Caddyfile && sudo syste pm2 start infra/single-box/ecosystem.config.js --only web,cards-web && pm2 save # 6. firewall (SSH first, then the service ports, in and out) sudo ufw allow in 22/tcp -for p in 3000 3002 8001 8002 8090; do sudo ufw allow in $p/tcp; sudo ufw allow out $p/tcp; done +for p in 80 443 3000 3002 8001 8002 8090; do sudo ufw allow in $p/tcp; sudo ufw allow out $p/tcp; done sudo ufw enable ``` @@ -51,6 +90,57 @@ It refuses to boot without the Zynd developer keypair at re-derives every active persona's key on startup. Use the same key the live persona boxes use, or every derived key is wrong. +## CI/CD + +`.github/workflows/ci.yml` runs on PRs to `main`/`dev` (and is called by the +deploy workflow for pushes): web lint, typecheck and build, and the three +Python suites. Both gates are +**ratchets**: `main` has pre-existing failures (AGENTS.md §5 baselines; ESLint +errors AGENTS.md doesn't list), so they fail only on *new* ones. The numbers +live in `ci.yml` (`baseline` / `lint_baseline`); lower one when you fix +something, never raise it to turn a build green. + +`.github/workflows/deploy-single-box.yml` runs on every push to `main` (deploys +**prod**) and to `dev` (deploys **dev**): it calls CI, and if that passes it +SSHes to the box and runs [`deploy.sh`](deploy.sh) for that environment, which +moves that checkout to the new commit, rebuilds and restarts **only the +services whose files changed**, and health-checks them. A failed web build +restores the previous `.next` and restarts nothing. It never applies DB +migrations and never touches Caddy (it prints a note if the Caddyfile +drifted). Manual runs (Actions -> deploy-single-box -> Run workflow) skip the +CI checks and take `environment` (prod or dev), `ref` (a commit on that +environment's branch, to roll back), `force_all` and `plan_only`. + +By hand on the box: `infra/single-box/deploy.sh [--env=dev|prod] [--plan] [--force-all] [sha]`. +The forced-command SSH key below runs prod's checkout's copy of the script, +which routes to either environment, so it must be a version that knows `--env`. + +### One-time setup (needs a person: it grants access and sets repo secrets) + +1. **On the box, as root:** let `ubuntu` drive Docker and add a CI key that can + run *only* `deploy.sh` (no shell, no forwarding): + ```bash + usermod -aG docker ubuntu # docker group is root-equivalent; the forced-command key below is what limits it + install -d -m700 -o ubuntu -g ubuntu /home/ubuntu/.ssh + echo 'restrict,command="/home/ubuntu/zynd-platform/infra/single-box/deploy.sh" github-actions-deploy' >> /home/ubuntu/.ssh/authorized_keys + chown ubuntu:ubuntu /home/ubuntu/.ssh/authorized_keys && chmod 600 /home/ubuntu/.ssh/authorized_keys + ``` + Generate the pair with `ssh-keygen -t ed25519 -N '' -C github-actions-deploy -f ci_deploy`. +2. **GitHub -> Settings -> Secrets and variables -> Actions**, three repository secrets: + + | Secret | Value | + |---|---| + | `DEPLOY_HOST` | the box's IP or hostname | + | `DEPLOY_SSH_KEY` | the private key (`ci_deploy`), whole file | + | `DEPLOY_HOST_KEY` | the box's public host key: `cut -d' ' -f1-2 /etc/ssh/ssh_host_ed25519_key.pub` (pinned, not trust-on-first-use) | + +3. Optional: branch protection on `main` requiring the `ci` checks; an + Actions *environment* with required reviewers if deploys should need approval. + +Anyone who can push to `main` can run code as `ubuntu` on the box through this +pipeline, same as a normal CD setup. The workflow never runs for pull requests, +so forks can't reach the secrets. + ## Update ```bash @@ -74,6 +164,17 @@ memory `JWT_SECRET`; cards-api `MEMORY_SERVICE_TOKEN` = memory network (`MEMORY_LAYER_URL=http://api:8000`); persona-api over `http://127.0.0.1:8001`. +Where the domains go (server-side env, none committed). The web values are +baked in at build time: rebuild and `pm2 restart web cards-web` after changing. + +| File | Values | +|---|---| +| `apps/persona-web/.env.local` | `NEXT_PUBLIC_API_URL=https://persona.api.zynd.ai`, `NEXT_PUBLIC_MEMORY_API_URL` and `NEXT_PUBLIC_ZYND_API_URL=https://api.zynd.ai`, `NEXT_PUBLIC_PAGE_BASE_URL` and `NEXT_PUBLIC_SITE_URL=https://persona.zynd.ai` | +| `apps/cards-web/.env.local` | `NEXT_PUBLIC_API_URL=https://cards.api.zynd.ai`, `NEXT_PUBLIC_ZYND_API_URL=https://api.zynd.ai`, `NEXT_PUBLIC_SITE_URL=https://cards.zynd.ai` | +| `services/persona-api/.env` | `FRONTEND_URL` and `PUBLIC_PAGE_BASE_URL=https://persona.zynd.ai` (`FRONTEND_URL` is the only CORS origin) | +| `services/cards-api/.env.prod` | `FRONTEND_URL` and `SITE_BASE_URL=https://cards.zynd.ai`, `API_BASE_URL=https://cards.api.zynd.ai` | +| `services/memory/.env.prod` | `PUBLIC_BASE_URL=https://api.zynd.ai`, `MCP_PUBLIC_BASE_URL=https://api.zynd.ai/mcp`, `CORS_ORIGINS=https://persona.zynd.ai,https://cards.zynd.ai` | + Deliberate choices, same as the live dev channel (persona-api `CLAUDE.md`): - `ZYND_WEBHOOK_BASE_URL` and the OAuth redirect URIs **stay on prod** @@ -85,11 +186,13 @@ Deliberate choices, same as the live dev channel (persona-api `CLAUDE.md`): - cards-api runs against aafo's `cards` schema (`SUPABASE_DB_SCHEMA=cards`), trusting aafo's issuer; the tables are empty until the cards cutover. -## Limits of IP:port access +## Cutover caveats -- Sign-in (Supabase OAuth) and the OAuth "connect" flows redirect to the - Supabase / provider-registered URLs. `http://:3000/**` and - `http://:3002/**` are not in aafo's Auth redirect list, and the provider - callbacks point at prod, so browser login will not complete here until a - human adds them (or real domains are pointed at the box). -- Everything is plain HTTP. +- `persona.zynd.ai` and `api.zynd.ai` currently belong to the live boxes. + Pointing them here is a prod cutover: this box's memory Postgres is a fresh, + empty database, so users' memory data and MCP client grants stay on the old + api box until they are migrated. cards data is still on xmfj (cards-api here + reads aafo's empty `cards` schema). +- Sign-in uses aafo's Auth redirect list: `persona.zynd.ai/**` and + `cards.zynd.ai/**` are already in it; the web apps are built against the + domains, so they don't work from a bare IP. diff --git a/infra/single-box/deploy.sh b/infra/single-box/deploy.sh new file mode 100755 index 0000000..b1b1303 --- /dev/null +++ b/infra/single-box/deploy.sh @@ -0,0 +1,223 @@ +#!/usr/bin/env bash +# Server-side deploy for the single box. Runs as `ubuntu`. Two environments live +# on the box (infra/single-box/README.md): prod (checkout of `main`) and dev +# (checkout of `dev`). Called by .github/workflows/deploy-single-box.yml over SSH +# as a forced command, or by hand: +# +# deploy.sh # deploy the latest origin/main to prod +# deploy.sh --env=dev # deploy the latest origin/dev to dev +# deploy.sh [--env=..] # roll that environment to a commit on its branch +# deploy.sh [--env=..] --plan # show what would change, touch nothing +# deploy.sh [--env=..] --force-all # redeploy every service of that environment +# +# Arguments come from $SSH_ORIGINAL_COMMAND when run as the forced command. +# (`main` and `latest` both mean "the newest commit of the environment's branch".) +# +# What it does: moves that environment's checkout to the target commit, works +# out which services' files changed since the previous commit, rebuilds and +# restarts only those, then health-checks them. A failed web build restores the +# previous .next and restarts nothing, so the old version keeps serving. It never +# applies database migrations (packages/db is applied by a person) and never +# touches Caddy or DNS. +set -Eeuo pipefail +export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +BOX="${ZYND_BOX:-/home/ubuntu/.zynd-box}" + +log() { printf '[deploy %s] %s\n' "$(date -u +%T)" "$*"; } +die() { log "ERROR: $*"; exit 1; } + +# Everything that differs between the two environments lives here: checkout, +# branch, pm2 app names, ecosystem file, docker compose project and services, +# and the localhost ports the health checks hit. +configure_env() { + case "$1" in + prod) + REPO="${ZYND_ROOT:-/home/ubuntu/zynd-platform}"; BRANCH=main + PM2_API=api; PM2_WEB=web; PM2_CARDS_WEB=cards-web + ECOSYSTEM=infra/single-box/ecosystem.config.js + COMPOSE=(docker compose -p zynd -f infra/api-box/docker-compose.prod.yml + -f infra/single-box/docker-compose.override.yml --env-file "$BOX/compose.env") + COMPOSE_TOUCHED='^(infra/api-box/docker-compose\.prod\.yml|infra/single-box/docker-compose\.override\.yml)$' + MEMORY_SERVICES=(postgres redis api worker mcp); CARDS_SERVICES=(cards) + P_API=8000; P_WEB=3001; P_CARDS_WEB=3002; P_MEMORY=8001; P_CARDS_API=8002 ;; + dev) + REPO="${ZYND_ROOT_DEV:-/home/ubuntu/zynd-platform-dev}"; BRANCH=dev + PM2_API=api-dev; PM2_WEB=web-dev; PM2_CARDS_WEB=cards-web-dev + ECOSYSTEM=infra/single-box/ecosystem.dev.config.js + COMPOSE=(env "DEV_ROOT=$REPO" docker compose -p zynd-dev -f infra/single-box/docker-compose.dev.yml) + COMPOSE_TOUCHED='^infra/single-box/docker-compose\.dev\.yml$' + # No dev worker on purpose: memory's nightly cron jobs would run twice on the shared DB. + MEMORY_SERVICES=(api-dev mcp-dev); CARDS_SERVICES=(cards-dev) + P_API=8100; P_WEB=3101; P_CARDS_WEB=3102; P_MEMORY=8101; P_CARDS_API=8102 ;; + *) die "unknown environment '$1'" ;; + esac +} + +# ── Stage 1: parse args, take the lock, move the checkout, re-exec ──────── +# Re-exec so the (possibly just-updated) deploy.sh is what does the real work, +# instead of bash reading a file that git rewrote under it. +if [[ -z "${ZYND_DEPLOY_STAGE:-}" ]]; then + read -r -a words <<<"${SSH_ORIGINAL_COMMAND:-$*}" + ENV_NAME=prod; ref=""; PLAN_ONLY=0; FORCE_ALL=0 + for w in "${words[@]:-}"; do + case "$w" in + "") ;; + --plan) PLAN_ONLY=1 ;; + --force-all) FORCE_ALL=1 ;; + --env=prod|--env=dev) ENV_NAME="${w#--env=}" ;; + main|latest) ref="" ;; + *) [[ "$w" =~ ^[0-9a-f]{7,40}$ ]] || die "refusing argument '$w' (allowed: main|latest, a commit sha, --env=prod|dev, --plan, --force-all)" + ref="$w" ;; + esac + done + + configure_env "$ENV_NAME" + cd "$REPO" + mkdir -p "$BOX" + exec 9>"$BOX/deploy-$ENV_NAME.lock" + flock -n 9 || die "another $ENV_NAME deploy is already running" + + git fetch --quiet origin "$BRANCH" + target="$(git rev-parse --verify "${ref:-origin/$BRANCH}^{commit}")" || die "unknown commit '$ref'" + git merge-base --is-ancestor "$target" "origin/$BRANCH" || die "$target is not on origin/$BRANCH" + OLD_SHA="$(git rev-parse HEAD)" + + if [[ -n "$(git status --porcelain --untracked-files=no)" ]]; then + die "checkout has local modifications to tracked files; refusing to overwrite them: $(git status --porcelain --untracked-files=no | head -5 | tr '\n' ' ')" + fi + + if [[ "$PLAN_ONLY" == 0 ]]; then + git checkout -q "$BRANCH" + git reset -q --hard "$target" + fi + # Run stage 2 in a pipeline (not `exec` + process substitution): tee has + # flushed everything before we exit, so the SSH session never cuts the tail + # of the log. fd 9 (the lock) is inherited by the child. + rc=0 + ZYND_DEPLOY_STAGE=2 ZYND_ENV="$ENV_NAME" OLD_SHA="$OLD_SHA" NEW_SHA="$target" PLAN_ONLY="$PLAN_ONLY" FORCE_ALL="$FORCE_ALL" \ + "$REPO/infra/single-box/deploy.sh" 2>&1 | tee -a "$BOX/deploy-$ENV_NAME.log" || rc="${PIPESTATUS[0]}" + exit "$rc" +fi + +# ── Stage 2: decide and act ─────────────────────────────────────────────── +configure_env "$ZYND_ENV" +cd "$REPO" +trap 'log "[$ZYND_ENV] deploy FAILED at line $LINENO. Previous commit was ${OLD_SHA:0:12}; roll back with: deploy.sh --env=$ZYND_ENV ${OLD_SHA:0:12}"' ERR + +if [[ "$OLD_SHA" == "$NEW_SHA" && "$FORCE_ALL" == 0 ]]; then + log "[$ZYND_ENV] already at ${NEW_SHA:0:12}; nothing to deploy" + exit 0 +fi + +changed="$(git diff --name-only "$OLD_SHA" "$NEW_SHA")" +touched() { grep -qE "$1" <<<"$changed"; } +log "[$ZYND_ENV] ${OLD_SHA:0:12} -> ${NEW_SHA:0:12} ($(grep -c . <<<"$changed" || true) files changed)" + +do_persona_api=0; do_persona_web=0; do_cards_web=0; do_memory=0; do_cards_api=0; do_pm2_config=0 +if [[ "$FORCE_ALL" == 1 ]]; then + do_persona_api=1; do_persona_web=1; do_cards_web=1; do_memory=1; do_cards_api=1 +else + touched '^services/persona-api/' && do_persona_api=1 + touched '^apps/persona-web/' && do_persona_web=1 + touched '^apps/cards-web/' && do_cards_web=1 + touched '^services/memory/' && do_memory=1 + touched '^services/cards-api/' && do_cards_api=1 + if touched "$COMPOSE_TOUCHED"; then do_memory=1; do_cards_api=1; fi +fi +touched "^${ECOSYSTEM//./\\.}\$" && do_pm2_config=1 + +# dependency files: reinstall when they changed, or on --force-all +dep_changed() { [[ "$FORCE_ALL" == 1 ]] || touched "$1"; } +note() { if dep_changed "$1"; then printf '%s' "$2"; fi; } # always exits 0 (safe inside $(...) under set -e) + +plan=() +((do_persona_api)) && plan+=("persona-api (pm2 $PM2_API)$(note '^services/persona-api/requirements\.txt$' ' +pip install')") +((do_persona_web)) && plan+=("persona-web (pm2 $PM2_WEB)$(note '^apps/persona-web/package-lock\.json$' ' +npm ci') +build") +((do_cards_web)) && plan+=("cards-web (pm2 $PM2_CARDS_WEB)$(note '^apps/cards-web/package-lock\.json$' ' +npm ci') +build") +((do_memory)) && plan+=("memory (docker: ${MEMORY_SERVICES[*]})") +((do_cards_api)) && plan+=("cards-api (docker: ${CARDS_SERVICES[*]})") +((do_pm2_config)) && plan+=("pm2 ecosystem config changed: startOrRestart all $ZYND_ENV pm2 apps") +if ((${#plan[@]})); then printf '[plan] %s\n' "${plan[@]}"; else log "[plan] no service files changed; only the checkout moves"; fi +touched '^packages/db/' && log "NOTE: packages/db changed. Deploys never apply migrations; a person does (packages/db/README.md)." + +if [[ "$PLAN_ONLY" == 1 ]]; then log "--plan: nothing changed"; exit 0; fi + +# Caddy is not managed by deploys (needs root): just say when it drifted. +if ! cmp -s infra/single-box/Caddyfile /etc/caddy/Caddyfile 2>/dev/null; then + log "NOTE: infra/single-box/Caddyfile differs from /etc/caddy/Caddyfile. As root: install -m644 it there, then 'systemctl reload caddy'." +fi + +# ── actions ─────────────────────────────────────────────────────────────── +build_web() { # + local dir=$1 + ( + cd "$REPO/$dir" + if dep_changed "^${dir}/package-lock\.json$" || [[ ! -d node_modules ]]; then + log "$dir: npm ci"; npm ci --no-audit --no-fund + fi + rm -rf .next.prev + [[ -d .next ]] && cp -a .next .next.prev + log "$dir: npm run build" + if npm run build; then + rm -rf .next.prev + else + log "$dir: build FAILED; restoring the previous build, nothing restarted" + rm -rf .next + [[ -d .next.prev ]] && mv .next.prev .next + exit 1 + fi + ) +} + +# Builds first (any failure stops here, before anything is restarted). +((do_persona_web)) && build_web apps/persona-web +((do_cards_web)) && build_web apps/cards-web + +if ((do_persona_api)); then + if dep_changed '^services/persona-api/requirements\.txt$'; then + log "persona-api: pip install -r requirements.txt" + services/persona-api/.venv/bin/pip install --quiet -r services/persona-api/requirements.txt + fi +fi + +if ((do_memory)); then + log "memory: docker compose up --build (${MEMORY_SERVICES[*]})" + "${COMPOSE[@]}" up -d --build "${MEMORY_SERVICES[@]}" +fi +if ((do_cards_api)); then + log "cards-api: docker compose up --build (${CARDS_SERVICES[*]})" + "${COMPOSE[@]}" up -d --build "${CARDS_SERVICES[@]}" +fi + +if ((do_pm2_config)); then + log "pm2: startOrRestart $ECOSYSTEM" + pm2 startOrRestart "$ECOSYSTEM" --update-env +else + ((do_persona_api)) && { log "pm2: restart $PM2_API"; pm2 restart "$PM2_API" --update-env; } + ((do_persona_web)) && { log "pm2: restart $PM2_WEB"; pm2 restart "$PM2_WEB" --update-env; } + ((do_cards_web)) && { log "pm2: restart $PM2_CARDS_WEB"; pm2 restart "$PM2_CARDS_WEB" --update-env; } +fi +pm2 save >/dev/null + +# ── health checks (only what was touched) ───────────────────────────────── +wait_http() { # [tries] + local name=$1 url=$2 tries=${3:-60} i code=000 + for ((i = 1; i <= tries; i++)); do + code="$(curl -s -o /dev/null -m 5 -w '%{http_code}' "$url" || true)" + if [[ "$code" == 200 ]]; then log "healthy: $name"; return 0; fi + sleep 2 + done + log "UNHEALTHY: $name ($url answered $code)"; return 1 +} +failed=0 +((do_persona_api || do_pm2_config)) && { wait_http persona-api "http://127.0.0.1:$P_API/api/openapi.json" || failed=1; } +((do_persona_web || do_pm2_config)) && { wait_http persona-web "http://127.0.0.1:$P_WEB/" || failed=1; } +((do_cards_web || do_pm2_config)) && { wait_http cards-web "http://127.0.0.1:$P_CARDS_WEB/" || failed=1; } +((do_memory)) && { wait_http memory-api "http://127.0.0.1:$P_MEMORY/health" || failed=1; } +((do_cards_api)) && { wait_http cards-api "http://127.0.0.1:$P_CARDS_API/health" || failed=1; } + +if [[ "$failed" == 1 ]]; then + die "[$ZYND_ENV] health checks failed after deploying ${NEW_SHA:0:12}. Roll back with: deploy.sh --env=$ZYND_ENV ${OLD_SHA:0:12}" +fi +log "[$ZYND_ENV] deployed ${NEW_SHA:0:12} OK" diff --git a/infra/single-box/docker-compose.dev.yml b/infra/single-box/docker-compose.dev.yml new file mode 100644 index 0000000..6c5e225 --- /dev/null +++ b/infra/single-box/docker-compose.dev.yml @@ -0,0 +1,49 @@ +# Dev instance's containers (branch `dev`), next to prod's on the same box. +# Separate compose project (`zynd-dev`) from prod's (`zynd`), but on prod's +# network so they reach the same Postgres and Redis ("same DB for now"). +# +# What is here, and what deliberately is not: +# - api-dev, mcp-dev memory's API and MCP server running dev-branch code +# - cards-dev cards-api running dev-branch code +# - NO worker: memory's nightly cron jobs (decay, resolution, recompute, +# orphan cleanup) would run twice on the shared database. Jobs the dev API +# enqueues are picked up by prod's worker from the shared Redis. +# - NO postgres/redis of its own. +# Ports are published on 127.0.0.1 only; host Caddy serves them on the dev.* +# domains (infra/single-box/Caddyfile). +# +# Usage: DEV_ROOT=/home/ubuntu/zynd-platform-dev docker compose -p zynd-dev \ +# -f infra/single-box/docker-compose.dev.yml up -d --build +# (deploy.sh --env=dev does exactly this.) Requires prod's stack to be up: it +# owns the `zynd_default` network, `postgres` and `redis`. +# +# Env files: ${DEV_ROOT}/services/memory/.env.prod and .../cards-api/.env.prod, +# generated from prod's with the dev.* URLs; memory-dev points at the shared DB +# and `http://api-dev:8000` is how cards-dev reaches memory-dev. +services: + api-dev: + build: ${DEV_ROOT}/services/memory + env_file: ${DEV_ROOT}/services/memory/.env.prod + command: sh -c "python -m app.schema_apply && uvicorn app.main:app --host 0.0.0.0 --port 8000" + ports: + - "127.0.0.1:8101:8000" + restart: unless-stopped + mcp-dev: + build: ${DEV_ROOT}/services/memory + env_file: ${DEV_ROOT}/services/memory/.env.prod + command: uvicorn app.mcp_http:app --host 0.0.0.0 --port 8090 + ports: + - "127.0.0.1:8190:8090" + restart: unless-stopped + cards-dev: + build: ${DEV_ROOT}/services/cards-api + env_file: ${DEV_ROOT}/services/cards-api/.env.prod + command: uvicorn main:app --host 0.0.0.0 --port 8000 + ports: + - "127.0.0.1:8102:8000" + restart: unless-stopped + +networks: + default: + name: zynd_default + external: true diff --git a/infra/single-box/ecosystem.dev.config.js b/infra/single-box/ecosystem.dev.config.js new file mode 100644 index 0000000..1231bfc --- /dev/null +++ b/infra/single-box/ecosystem.dev.config.js @@ -0,0 +1,59 @@ +// PM2 manifest for the DEV instance on the single box (branch `dev`, checkout +// /home/ubuntu/zynd-platform-dev). Prod's is ecosystem.config.js; both run under +// the same pm2 daemon, so every name and port here is different from prod's. +// +// pm2 start infra/single-box/ecosystem.dev.config.js +// pm2 restart api-dev web-dev cards-web-dev +// +// Ports (all 127.0.0.1; host Caddy serves them on the dev.* domains): +// persona-api 8100, persona-web 3101, cards-web 3102. The dev containers +// (memory 8101, mcp 8190, cards-api 8102) come from docker-compose.dev.yml. +// `next start` needs `npm run build` first; NEXT_PUBLIC_* are baked in at build. +const ROOT = process.env.ZYND_ROOT_DEV || "/home/ubuntu/zynd-platform-dev"; + +module.exports = { + apps: [ + { + name: "api-dev", // persona-api + cwd: `${ROOT}/services/persona-api`, + script: `${ROOT}/services/persona-api/.venv/bin/uvicorn`, + args: "main:app --host 127.0.0.1 --port 8100 --workers 1 --loop asyncio", + interpreter: "none", + instances: 1, + autorestart: true, + max_restarts: 10, + max_memory_restart: "4G", + env: { PYTHONUNBUFFERED: "1", PYTHONFAULTHANDLER: "1" }, + merge_logs: true, + time: true, + }, + { + name: "web-dev", // persona-web + cwd: `${ROOT}/apps/persona-web`, + script: "/usr/bin/npx", + args: "next start -H 127.0.0.1 -p 3101", + interpreter: "none", + instances: 1, + autorestart: true, + max_restarts: 10, + max_memory_restart: "1G", + env: { NODE_ENV: "production", PORT: "3101", HOSTNAME: "127.0.0.1" }, + merge_logs: true, + time: true, + }, + { + name: "cards-web-dev", + cwd: `${ROOT}/apps/cards-web`, + script: "/usr/bin/npx", + args: "next start -H 127.0.0.1 -p 3102", + interpreter: "none", + instances: 1, + autorestart: true, + max_restarts: 10, + max_memory_restart: "1G", + env: { NODE_ENV: "production", PORT: "3102", HOSTNAME: "127.0.0.1" }, + merge_logs: true, + time: true, + }, + ], +}; diff --git a/packages/db/OWNERS.md b/packages/db/OWNERS.md index a938737..8b0dc01 100644 --- a/packages/db/OWNERS.md +++ b/packages/db/OWNERS.md @@ -17,7 +17,7 @@ every service listed as a reader. | `linkedin_profiles`, `twitter_profiles`, `github_profiles` | persona | — | | `enriched_contacts`, `enriched_companies`, `suggested_contacts`, `suggested_contact_runs` | persona | — | | `chat_messages`, `brief_todos`, `published_pages` | persona | — | -| `cards.agent_profile_cards`, `cards.x_accounts`, `cards.x_mentions`, `cards.x_conversations` | cards | — (service role only; cards-web goes through cards-api) | +| `cards.agent_profile_cards`, `cards.x_accounts`, `cards.x_mentions`, `cards.x_conversations`, `cards.keyword_posts` | cards | — (service role only; cards-web goes through cards-api) | | `identity.*` (empty today; Zynd Account tables in Stage 2) | shared | persona, cards | ## Functions, triggers, publication diff --git a/packages/db/cards/migrations/0003_keyword_posts.sql b/packages/db/cards/migrations/0003_keyword_posts.sql new file mode 100644 index 0000000..305b53b --- /dev/null +++ b/packages/db/cards/migrations/0003_keyword_posts.sql @@ -0,0 +1,15 @@ +-- owner: cards +-- Shared daily cache of social posts per interest keyword. cards-api uses +-- this for GET /cards/by-handle/{handle}/suggested-posts so the same keyword +-- is fetched once per UTC day, not once per user. +-- Rollback: DROP TABLE IF EXISTS cards.keyword_posts; +CREATE TABLE "cards"."keyword_posts" ( + "keyword" text PRIMARY KEY NOT NULL, + "fetched_on" date NOT NULL, + "posts" jsonb DEFAULT '[]'::jsonb NOT NULL, + "summary" text DEFAULT '' NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "cards"."keyword_posts" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE POLICY "service role full access on keyword_posts" ON "cards"."keyword_posts" AS PERMISSIVE FOR ALL TO "service_role" USING (true) WITH CHECK (true); diff --git a/packages/db/cards/migrations/meta/0003_snapshot.json b/packages/db/cards/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..e89ee4d --- /dev/null +++ b/packages/db/cards/migrations/meta/0003_snapshot.json @@ -0,0 +1,562 @@ +{ + "id": "c3e9a1b4-7d52-4f80-9e1a-2b6c8d0f4a11", + "prevId": "7c7baa8d-88e6-42c2-834f-56ee9f9cf5a2", + "version": "7", + "dialect": "postgresql", + "tables": { + "cards.agent_profile_cards": { + "name": "agent_profile_cards", + "schema": "cards", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "handle_github": { + "name": "handle_github", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle_x": { + "name": "handle_x", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "card": { + "name": "card", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "search_tsv": { + "name": "search_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "type": "stored", + "as": "to_tsvector('english'::regconfig, ((((((COALESCE(((card -> 'identity'::text) ->> 'name'::text), ''::text) || ' '::text) || COALESCE(((card -> 'identity'::text) ->> 'headline'::text), ''::text)) || ' '::text) || COALESCE((card ->> 'summary'::text), ''::text)) || ' '::text) || cards.skill_names(card)))" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "scrape_raw": { + "name": "scrape_raw", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "user_intent": { + "name": "user_intent", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "owner_email": { + "name": "owner_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "suggested_posts": { + "name": "suggested_posts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "claim_token_hash": { + "name": "claim_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agent_profile_cards_status_idx": { + "name": "agent_profile_cards_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "agent_profile_cards_tsv_idx": { + "name": "agent_profile_cards_tsv_idx", + "columns": [ + { + "expression": "search_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "gin", + "concurrently": false + }, + "agent_profile_cards_embedding_hnsw_idx": { + "name": "agent_profile_cards_embedding_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "hnsw", + "concurrently": false + }, + "idx_cards_owner_email": { + "name": "idx_cards_owner_email", + "columns": [ + { + "expression": "owner_email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "agent_profile_cards_owner_user_id_idx": { + "name": "agent_profile_cards_owner_user_id_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "agent_profile_cards_owner_user_id_fkey": { + "name": "agent_profile_cards_owner_user_id_fkey", + "tableFrom": "agent_profile_cards", + "columnsFrom": [ + "owner_user_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "agent_profile_cards_handle_key": { + "name": "agent_profile_cards_handle_key", + "columns": [ + "handle" + ], + "nullsNotDistinct": false + } + }, + "policies": { + "service role full access on cards": { + "name": "service role full access on cards", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "service_role" + ], + "using": "true", + "withCheck": "true" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cards.x_accounts": { + "name": "x_accounts", + "schema": "cards", + "columns": { + "x_user_id": { + "name": "x_user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "x_accounts_card_id_fkey": { + "name": "x_accounts_card_id_fkey", + "tableFrom": "x_accounts", + "columnsFrom": [ + "card_id" + ], + "tableTo": "agent_profile_cards", + "schemaTo": "cards", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "service role full access on x_accounts": { + "name": "service role full access on x_accounts", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "service_role" + ], + "using": "true", + "withCheck": "true" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cards.x_conversations": { + "name": "x_conversations", + "schema": "cards", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "x_user_id": { + "name": "x_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'initial'" + }, + "current_question": { + "name": "current_question", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "answered": { + "name": "answered", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "x_conversations_user_idx": { + "name": "x_conversations_user_idx", + "columns": [ + { + "expression": "x_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "x_conversations_card_id_fkey": { + "name": "x_conversations_card_id_fkey", + "tableFrom": "x_conversations", + "columnsFrom": [ + "card_id" + ], + "tableTo": "agent_profile_cards", + "schemaTo": "cards", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "service role full access on x_conversations": { + "name": "service role full access on x_conversations", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "service_role" + ], + "using": "true", + "withCheck": "true" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cards.x_mentions": { + "name": "x_mentions", + "schema": "cards", + "columns": { + "tweet_id": { + "name": "tweet_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "x_user_id": { + "name": "x_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'processed'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "x_mentions_user_idx": { + "name": "x_mentions_user_idx", + "columns": [ + { + "expression": "x_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "service role full access on x_mentions": { + "name": "service role full access on x_mentions", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "service_role" + ], + "using": "true", + "withCheck": "true" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cards.keyword_posts": { + "name": "keyword_posts", + "schema": "cards", + "columns": { + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "fetched_on": { + "name": "fetched_on", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "posts": { + "name": "posts", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "service role full access on keyword_posts": { + "name": "service role full access on keyword_posts", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "service_role" + ], + "using": "true", + "withCheck": "true" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": { + "cards": "cards" + }, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/cards/migrations/meta/_journal.json b/packages/db/cards/migrations/meta/_journal.json index daddd17..98d5358 100644 --- a/packages/db/cards/migrations/meta/_journal.json +++ b/packages/db/cards/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1790431288763, "tag": "0002_cards_search_functions", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1790800000000, + "tag": "0003_keyword_posts", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/cards/schema/index.ts b/packages/db/cards/schema/index.ts index 00aa651..8c597d2 100644 --- a/packages/db/cards/schema/index.ts +++ b/packages/db/cards/schema/index.ts @@ -1,3 +1,4 @@ export * from './_schema'; export * from './cards'; export * from './x_bot'; +export * from './keyword_posts'; diff --git a/packages/db/cards/schema/keyword_posts.ts b/packages/db/cards/schema/keyword_posts.ts new file mode 100644 index 0000000..22979a9 --- /dev/null +++ b/packages/db/cards/schema/keyword_posts.ts @@ -0,0 +1,26 @@ +import { sql } from 'drizzle-orm'; +import { date, jsonb, pgPolicy, text } from 'drizzle-orm/pg-core'; +import { jsonbDefault, serviceRole, tstz } from '../../lib/shared'; +import { cards } from './_schema'; + +// owner: cards. Shared daily cache of social posts per interest keyword. +// cards-api reads/writes this for GET /cards/by-handle/{handle}/suggested-posts. +export const keywordPosts = cards.table( + 'keyword_posts', + { + keyword: text('keyword').primaryKey(), + fetchedOn: date('fetched_on').notNull(), + posts: jsonb('posts').notNull().default(jsonbDefault('[]')), + summary: text('summary').notNull().default(''), + updatedAt: tstz('updated_at').notNull().defaultNow(), + }, + () => [ + pgPolicy('service role full access on keyword_posts', { + as: 'permissive', + for: 'all', + to: serviceRole, + using: sql`true`, + withCheck: sql`true`, + }), + ], +); diff --git a/services/cards-api/.env.example b/services/cards-api/.env.example index 73557e2..5196a44 100644 --- a/services/cards-api/.env.example +++ b/services/cards-api/.env.example @@ -30,3 +30,8 @@ FRONTEND_URL=http://localhost:3002 INDEXNOW_KEY= BING_API_KEY= BING_SITE_URL=https://zynd.ai + +# ── QuickEnrich (optional; free people search for suggested-people outsiders) ── +QUICKENRICH_BASE_URL= +QUICKENRICH_API_KEY= +QUICKENRICH_AUTH_HEADER=Authorization diff --git a/services/cards-api/.env.prod.example b/services/cards-api/.env.prod.example index d65cb3b..a915048 100644 --- a/services/cards-api/.env.prod.example +++ b/services/cards-api/.env.prod.example @@ -32,3 +32,8 @@ FRONTEND_URL=https://www.zynd.ai INDEXNOW_KEY= BING_API_KEY= BING_SITE_URL=https://www.zynd.ai + +# ── QuickEnrich (optional; free people search for suggested-people outsiders) ── +QUICKENRICH_BASE_URL= +QUICKENRICH_API_KEY= +QUICKENRICH_AUTH_HEADER=Authorization diff --git a/services/cards-api/api/cards.py b/services/cards-api/api/cards.py index 0efb5bc..d17daa8 100644 --- a/services/cards-api/api/cards.py +++ b/services/cards-api/api/cards.py @@ -273,6 +273,26 @@ async def refresh_memory( return {"zynd_memory": zynd_memory} +@router.get("/by-handle/{handle}/suggested-posts") +async def suggested_posts(handle: str): + from services.suggested_posts import get_suggested_posts + + result = await asyncio.to_thread(get_suggested_posts, handle) + if not result: + raise HTTPException(status_code=404, detail="card not found") + return result + + +@router.get("/by-handle/{handle}/suggested-people") +async def suggested_people(handle: str): + from services.suggested_people import get_suggested_people + + result = await asyncio.to_thread(get_suggested_people, handle) + if not result: + raise HTTPException(status_code=404, detail="card not found") + return result + + @router.get("/{card_id}") async def get_card(card_id: str): card = await asyncio.to_thread(cards_service.get_card, card_id) diff --git a/services/cards-api/api/onboard.py b/services/cards-api/api/onboard.py index 9cb482a..805402f 100644 --- a/services/cards-api/api/onboard.py +++ b/services/cards-api/api/onboard.py @@ -11,7 +11,7 @@ import config from api.auth import verify_supabase_jwt -from models.card import AgentProfileCard +from models.card import AgentProfileCard, normalize_booking_url from publish import hooks from scraping import github as github_scraper from scraping import linkedin as linkedin_scraper @@ -46,6 +46,12 @@ def _classify_url(url: str) -> str: return "website" +def _profile_link(url: str) -> str: + """A pasted profile URL as a card link: share-sheet query strings and fragments dropped.""" + from urllib.parse import urlparse + return urlparse(url)._replace(query="", fragment="").geturl() + + def _handle_from_url(url: str) -> str | None: from urllib.parse import urlparse parts = urlparse(url).path.strip("/").split("/") @@ -123,6 +129,7 @@ async def _run_pipeline(job_id: str, urls: list[str], resume_text: str | None) - linkedin_stats_data: dict | None = None linkedin_url_used: str | None = None + x_url_used: str | None = None if expanded: results = await asyncio.gather(*[_safe_fetch_url(u) for u in expanded]) for (orig_url, (kind, text, stats)) in zip(expanded, results): @@ -132,6 +139,8 @@ async def _run_pipeline(job_id: str, urls: list[str], resume_text: str | None) - x_texts.append(text) if stats and not x_stats_data: x_stats_data = stats + if not x_url_used: + x_url_used = orig_url elif kind == "linkedin": linkedin_texts.append(text) if stats and not linkedin_stats_data: @@ -157,9 +166,14 @@ async def _run_pipeline(job_id: str, urls: list[str], resume_text: str | None) - linkedin_text="\n\n".join(linkedin_texts) or None, ) - # Store LinkedIn URL in identity.links so refresh-linkedin endpoint can use it + # A profile URL the user gave us, and that scraped fine, beats the LLM's + # rendering of it: the LLM "tidies" slugs (drops LinkedIn's -798a6a228 + # style suffix), which breaks the link and refresh-linkedin, which + # re-scrapes whatever is stored here. if linkedin_url_used: - synth.identity.links.setdefault("linkedin", linkedin_url_used) + synth.identity.links["linkedin"] = _profile_link(linkedin_url_used) + if x_url_used: + synth.identity.links["x"] = _profile_link(x_url_used) # Deterministic avatar priority: LinkedIn > X > GitHub. The LLM's guess # (if any) is overridden by real scraped photo URLs. @@ -286,8 +300,10 @@ async def publish_card( card.love_talking_about = [x.strip() for x in answers["love_talking"].split(",") if x.strip()] if answers.get("location") and not card.identity.location: card.identity.location = answers["location"] - if answers.get("calendly_url"): - card.calendly_url = answers["calendly_url"].strip() or None + # Attribute assignment skips the model's validators, so normalize here. + for key in ("calendly_url", "google_calendar_url"): + if answers.get(key): + setattr(card, key, normalize_booking_url(answers[key])) user_intent = answers if answers else None diff --git a/services/cards-api/config.py b/services/cards-api/config.py index 59891bd..f64d293 100644 --- a/services/cards-api/config.py +++ b/services/cards-api/config.py @@ -88,6 +88,12 @@ MEMORY_SERVICE_TOKEN: str = os.getenv("MEMORY_SERVICE_TOKEN", "") MEMORY_REFRESH_INTERVAL_HOURS: int = int(os.getenv("MEMORY_REFRESH_INTERVAL_HOURS", "6")) +# ── QuickEnrich (free contact-finder only; cards-api owns this client) ── +QUICKENRICH_BASE_URL: str = os.getenv("QUICKENRICH_BASE_URL", "") +QUICKENRICH_API_KEY: str = os.getenv("QUICKENRICH_API_KEY", "") +QUICKENRICH_AUTH_HEADER: str = os.getenv("QUICKENRICH_AUTH_HEADER", "Authorization") +QUICKENRICH_TIMEOUT: float = float(os.getenv("QUICKENRICH_TIMEOUT", "30")) + def _get_supabase(): global _sb_service diff --git a/services/cards-api/models/card.py b/services/cards-api/models/card.py index f9845ba..ed95b96 100644 --- a/services/cards-api/models/card.py +++ b/services/cards-api/models/card.py @@ -1,3 +1,5 @@ +from urllib.parse import urlparse + from pydantic import BaseModel, Field, field_validator, model_validator @@ -20,6 +22,18 @@ def _coerce_experience(v): return None +def normalize_booking_url(v) -> str | None: + """Booking links render as a public : add a missing https:// (people + paste "calendly.com/me"), and drop anything that still isn't an http(s) URL.""" + if not isinstance(v, str) or not v.strip(): + return None + url = v.strip() + if "://" not in url: + url = f"https://{url}" + parsed = urlparse(url) + return url if parsed.scheme in ("http", "https") and parsed.netloc else None + + def _coerce_industries(v): if v in (None, ""): return [] @@ -140,6 +154,10 @@ class AgentProfileCard(BaseModel): x_stats: dict | None = None contribution_stats: dict | None = None calendly_url: str | None = None + # Google Calendar appointment-schedule booking page (calendar.app.google/… + # or calendar.google.com/calendar/appointments/…). Separate from + # calendly_url so agents reading the card never see a mislabelled link. + google_calendar_url: str | None = None # Structured LinkedIn work experience entries extracted at scrape time. # Each entry: {title, company, company_logo, employment_type, start_date, # end_date, duration, location, description} @@ -158,6 +176,11 @@ def _coerce_experience_card(cls, v): def _coerce_industries_card(cls, v): return _coerce_industries(v) + @field_validator("calendly_url", "google_calendar_url", mode="before") + @classmethod + def _normalize_booking_urls(cls, v): + return normalize_booking_url(v) + class CardSynthesis(BaseModel): """The subset the LLM produces; id/status/timestamps are assembled server-side.""" diff --git a/services/cards-api/services/cards.py b/services/cards-api/services/cards.py index 36789f9..68fc961 100644 --- a/services/cards-api/services/cards.py +++ b/services/cards-api/services/cards.py @@ -115,7 +115,7 @@ def assemble_card( user = github_data.get("user", {}) html_url = user.get("html_url") if html_url: - synth.identity.links.setdefault("github", html_url) + synth.identity.links["github"] = html_url # the API's URL, not the LLM's guess sources.append( Source( platform="github", diff --git a/services/cards-api/services/suggested_people.py b/services/cards-api/services/suggested_people.py new file mode 100644 index 0000000..1d74b3c --- /dev/null +++ b/services/cards-api/services/suggested_people.py @@ -0,0 +1,312 @@ +"""Recommend published ZYND cards with overlapping profile interests.""" +from __future__ import annotations + +import logging +from urllib.parse import urlparse + +import httpx + +import config +from models.card import AgentProfileCard +from services import cards as cards_service + +logger = logging.getLogger(__name__) + +FIELDS = ("working_on", "can_help_with", "connect_with", "love_talking_about") + +KEYWORDS = { + "working_on": [ + "Building a startup", "Building with AI", "Open source", "Side project", + "Indie hacking", "B2B SaaS", "Consumer apps", "Deep tech / research", + "Freelancing", "At a company", "Doing research", "Grad school", + "Writing", "Teaching / mentoring", "Community building", + "Design / creative work", "Investing", "Job hunting", + ], + "can_help_with": [ + "Code review", "System design", "ML / AI", "Cloud / infra", + "Data / analytics", "Security", "Technical interviews", "Hiring", + "Career advice", "Fundraising", "Pitching / storytelling", "Sales", + "Marketing", "Go-to-market", "Design", "Legal / compliance", + "Immigration / visas", "Public speaking", + ], + "connect_with": [ + "Founders", "Investors", "Engineers", "ML Researchers", + "Product Managers", "Designers", "Operators", "Scientists", + "Recruiters", "Mentors", "Potential co-founders", "Customers", + "Students", "Writers", "DevRel", "Researchers in my field", + "Healthcare builders", "Robotics people", + ], + "love_talking_about": [ + "AI / ML", "Agentic AI", "Startups", "Developer tools", "Open source", + "Web3 / Crypto", "Climate tech", "Robotics", "Hardware", "Design", + "Research", "SaaS", "Engineering culture", "Books", "Philosophy", + "Personal finance", "Gaming", "History / science", + ], +} + +_ALIASES = { + "working_on": { + "developing ai agents": "Building with AI", + "building decentralized ai agents": "Building with AI", + "building ai agents": "Building with AI", + "mobile app development": "Consumer apps", + }, + "can_help_with": { + "ml ai": "ML / AI", + "cloud architecture": "Cloud / infra", + "cloud infrastructure": "Cloud / infra", + "backend development": "System design", + }, + "connect_with": { + "ai enthusiasts": "ML Researchers", + "software engineers": "Engineers", + "ai researchers": "ML Researchers", + "other developers": "Engineers", + "other web3 developers": "Engineers", + "founders and entrepreneurs": "Founders", + "potential cofounders": "Potential co-founders", + "co-founders": "Potential co-founders", + }, + "love_talking_about": { + "ai technology": "AI / ML", + "ai technologies": "AI / ML", + "blockchain enthusiasts": "Web3 / Crypto", + "blockchain technology": "Web3 / Crypto", + "web3 technologies": "Web3 / Crypto", + "open-source projects": "Open source", + "open-source contributors": "Open source", + }, +} + +_CANON = { + field: {label.casefold(): label for label in labels} + for field, labels in KEYWORDS.items() +} + + +def canonical_keyword(field: str, value: str) -> str | None: + if not isinstance(value, str): + return None + normalized = " ".join(value.strip().casefold().split()) + return _CANON.get(field, {}).get(normalized) or _ALIASES.get(field, {}).get(normalized) + + +def _card_interests(card: AgentProfileCard) -> dict[str, set[str]]: + return { + field: { + canonical + for value in (getattr(card, field) or []) + if (canonical := canonical_keyword(field, value)) + } + for field in FIELDS + } + + +_TITLE_FOR = { + "Founders": "Founder", + "Investors": "Investor", + "Engineers": "Software Engineer", + "ML Researchers": "Machine Learning Engineer", + "Product Managers": "Product Manager", + "Designers": "Designer", + "Operators": "COO", + "Scientists": "Scientist", + "Recruiters": "Recruiter", + "Mentors": "Mentor", + "Potential co-founders": "Co-Founder", + "Students": "Student", + "Writers": "Writer", + "DevRel": "Developer Advocate", + "Researchers in my field": "Researcher", + "Healthcare builders": "Healthcare", + "Robotics people": "Robotics Engineer", +} + +_KEYWORD_FOR = { + "Building with AI": "AI", + "Building a startup": "startup", + "Open source": "open source", + "B2B SaaS": "SaaS", + "Consumer apps": "consumer", + "Deep tech / research": "deep tech", + "Indie hacking": "indie hacker", + "ML / AI": "AI", + "AI / ML": "AI", + "Agentic AI": "AI agents", + "Startups": "startup", + "Developer tools": "developer tools", + "Web3 / Crypto": "crypto", + "Climate tech": "climate", + "Robotics": "robotics", + "SaaS": "SaaS", +} + + +def _linkedin_key(url: str) -> str: + raw = (url or "").strip().lower().rstrip("/") + if not raw: + return "" + parsed = urlparse(raw if "://" in raw else f"https://{raw}") + host = (parsed.hostname or "").removeprefix("www.") + path = parsed.path.rstrip("/") + return f"{host}{path}" if host else "" + + +def _india_body() -> dict: + return { + "page": 1, + "per_page": 4, + "country_code": {"include": ["IN"], "exclude": []}, + } + + +def outside_search_bodies(card: AgentProfileCard) -> list[dict]: + interests = _card_interests(card) + titles = [_TITLE_FOR[k] for k in KEYWORDS["connect_with"] if k in interests["connect_with"]][:2] + keywords = list(dict.fromkeys( + _KEYWORD_FOR[k] + for field in ("working_on", "love_talking_about") + for k in KEYWORDS[field] + if k in interests[field] and k in _KEYWORD_FOR + ))[:2] + bodies = [] + if titles: + bodies.append({**_india_body(), "title": {"include": titles, "exclude": []}}) + if keywords: + bodies.append({**_india_body(), "bio_li": {"include": keywords, "exclude": []}}) + return bodies + + +def _shape_outside(record: dict) -> dict | None: + def field(key: str) -> str: + text = str(record.get(key) or "").strip() + return "" if text.upper() == "N/A" else text + + url = field("employee_linkedin") + name = " ".join(part for part in (field("first_name"), field("last_name")) if part) + if not url and not name: + return None + return { + "name": name, + "title": field("title"), + "company": field("company_name"), + "linkedin_url": url, + "source": "quickenrich", + } + + +def _qe_headers() -> dict: + header = (config.QUICKENRICH_AUTH_HEADER or "Authorization").strip() + key = config.QUICKENRICH_API_KEY + return { + header: f"Bearer {key}" if header.lower() == "authorization" else key, + "Accept": "application/json", + } + + +def _contact_finder(body: dict) -> list[dict]: + url = f"{config.QUICKENRICH_BASE_URL.rstrip('/')}/api/employees/contact-finder" + try: + resp = httpx.post(url, json=body, headers=_qe_headers(), timeout=config.QUICKENRICH_TIMEOUT) + if resp.status_code >= 400: + logger.warning( + "QuickEnrich contact-finder failed: HTTP %s %s", + resp.status_code, + (resp.text or "")[:300], + ) + return [] + payload = resp.json() + except Exception as exc: + logger.warning("QuickEnrich contact-finder failed: %s", exc) + return [] + records = payload.get("data") if isinstance(payload, dict) else payload + return [ + shaped + for record in records or [] + if isinstance(record, dict) and (shaped := _shape_outside(record)) + ] + + +def search_outside(card: AgentProfileCard, _taken: set[str]) -> list[dict]: + if not config.QUICKENRICH_BASE_URL or not config.QUICKENRICH_API_KEY: + return [] + out = [] + seen = set() + for body in outside_search_bodies(card): + for person in _contact_finder(body): + url = person.get("linkedin_url") or "" + key = url or person.get("name") + if not key or key in seen: + continue + seen.add(key) + out.append(person) + break + return out + + +def _public_person(card: AgentProfileCard, handle: str, matched: dict[str, list[str]]) -> dict: + return { + "handle": handle, + "name": card.identity.name, + "headline": card.identity.headline, + "location": card.identity.location, + "avatar_url": card.identity.avatar_url, + "url": f"{config.SITE_BASE_URL.rstrip('/')}/p/{handle}", + "match_score": sum(map(len, matched.values())), + "matched_fields": matched, + } + + +def _card_linkedin_key(card: AgentProfileCard) -> str: + links = card.identity.links or {} + return _linkedin_key(links.get("linkedin") or "") + + +def get_suggested_people(handle: str) -> dict | None: + requester = cards_service.get_card_by_handle(handle) + if not requester: + return None + interests = _card_interests(requester) + if not any(interests.values()): + return {"handle": handle, "people": [], "outside": []} + + results = [] + taken = set() + if key := _card_linkedin_key(requester): + taken.add(key) + rows = cards_service.list_published_rows(columns="card,handle") + for row in rows: + candidate_handle = row.get("handle") + if not candidate_handle or candidate_handle == handle: + continue + try: + candidate = AgentProfileCard.model_validate(row.get("card") or {}) + except Exception: + continue + if key := _card_linkedin_key(candidate): + taken.add(key) + candidate_interests = _card_interests(candidate) + matched = { + field: sorted(interests[field] & candidate_interests[field], key=str.casefold) + for field in FIELDS + } + matched = {field: words for field, words in matched.items() if words} + if matched: + candidate.handle = candidate_handle + results.append(_public_person(candidate, candidate_handle, matched)) + + results.sort(key=lambda person: (-person["match_score"], person["handle"].casefold())) + outside = [] + if config.QUICKENRICH_BASE_URL and config.QUICKENRICH_API_KEY: + for person in search_outside(requester, taken): + url = person.get("linkedin_url") or "" + key = _linkedin_key(url) + if key and key in taken: + continue + if key: + taken.add(key) + person.setdefault("source", "quickenrich") + outside.append(person) + if len(outside) == 2: + break + return {"handle": handle, "people": results[:2], "outside": outside} diff --git a/services/cards-api/services/suggested_posts.py b/services/cards-api/services/suggested_posts.py new file mode 100644 index 0000000..7de2db1 --- /dev/null +++ b/services/cards-api/services/suggested_posts.py @@ -0,0 +1,326 @@ +"""Shared keyword post cache. One post per card section per handle/day.""" +from __future__ import annotations + +import hashlib +from datetime import datetime, timezone + +import httpx + +import config +from models.card import AgentProfileCard, WritingSample +from services import cards as cards_service +from services.suggested_people import FIELDS, KEYWORDS + +_SHOWN_CAP = 28 +_APIFY_BASE = "https://api.apify.com/v2" +_ACTOR = "apidojo~tweet-scraper" + +_CANON = {k.lower(): k for opts in KEYWORDS.values() for k in opts} + + +def keyword_key(phrase: str) -> str: + return phrase.strip().lower() + + +def _canonical(phrase: str) -> str: + raw = (phrase or "").strip() + return _CANON.get(raw.lower(), raw) + + +def intent_queries(card: AgentProfileCard) -> list[tuple[str, str]]: + out = [] + for field in FIELDS: + phrases = getattr(card, field) or [] + first = next((_canonical(p) for p in phrases if str(p).strip()), "") + out.append((field, first)) + return out + + +def queries_hash(queries: list[tuple[str, str]]) -> str: + blob = "|".join(f"{f}:{q}" for f, q in queries) + return hashlib.sha256(blob.encode()).hexdigest()[:16] + + +def _seed(handle: str, day: str, field: str) -> int: + return int(hashlib.sha256(f"{handle}:{day}:{field}".encode()).hexdigest()[:8], 16) + + +def pick_day( + handle: str, + day: str, + queries: list[tuple[str, str]], + pool: dict, + shown_urls: list[str], +) -> tuple[list[dict | None], dict, list[str]]: + shown = list(shown_urls) + leftover = {k: list(v) for k, v in (pool or {}).items()} + posts: list[dict | None] = [] + for field, query in queries: + if not query: + posts.append(None) + continue + cands = leftover.get(field) or [] + avail = [c for c in cands if c.get("url") and c["url"] not in shown] + if not avail: + posts.append(None) + continue + chosen = avail[_seed(handle, day, field) % len(avail)] + posts.append({**chosen, "field": field, "query": query}) + shown.append(chosen["url"]) + leftover[field] = [c for c in cands if c.get("url") != chosen["url"]] + return posts, leftover, shown[-_SHOWN_CAP:] + + +def _tokens(text: str) -> set[str]: + return {t for t in text.lower().split() if t} + + +def linkedin_candidates(query: str, samples, exclude_urls: set[str]) -> list[dict]: + qtok = _tokens(query) + if not qtok: + return [] + out = [] + for s in samples or []: + platform = s.platform if isinstance(s, WritingSample) else s.get("platform") + url = s.url if isinstance(s, WritingSample) else s.get("url") or "" + excerpt = s.excerpt if isinstance(s, WritingSample) else s.get("excerpt") or "" + author = "" if isinstance(s, WritingSample) else s.get("author") or "" + posted_at = s.posted_at if isinstance(s, WritingSample) else s.get("posted_at") or "" + if platform != "linkedin" or not url or url in exclude_urls: + continue + if not qtok & _tokens(excerpt): + continue + out.append({ + "url": url, + "platform": "linkedin", + "excerpt": excerpt, + "author": author, + "posted_at": posted_at, + }) + return out + + +def _keyword_fresh(entry: dict | None, today: str) -> bool: + return bool(entry and entry.get("fetched_on") == today and entry.get("posts")) + + +def stitch_summary(queries: list[tuple[str, str]], keyword_cache: dict) -> str | None: + parts = [] + seen = set() + for _, query in queries: + if not query: + continue + key = keyword_key(query) + if key in seen: + continue + seen.add(key) + blurb = (keyword_cache.get(key) or {}).get("summary") or "" + if blurb: + parts.append(f"{query}: {blurb}") + return "\n\n".join(parts) or None + + +def refresh_snapshot( + handle: str, + card: AgentProfileCard, + snap: dict | None, + today: str, + keyword_cache: dict, + search_x, + linkedin_samples, + llm=None, +) -> tuple[dict, dict | None]: + queries = intent_queries(card) + qhash = queries_hash(queries) + snap = snap or {} + if snap.get("date") == today and snap.get("queries_hash") == qhash and snap.get("summary"): + return snap, None + + same_day = snap.get("date") == today and snap.get("queries_hash") == qhash + shown = list(snap.get("shown_urls") or []) + owner_urls = {s.url for s in (card.writing_samples or []) if s.url} + pool: dict = {} + + for field, query in queries: + if not query: + continue + key = keyword_key(query) + entry = keyword_cache.get(key) + if not _keyword_fresh(entry, today): + posts = list(search_x(query) or []) + seen = {p.get("url") for p in posts} + for p in linkedin_candidates(query, linkedin_samples, owner_urls): + if p.get("url") and p["url"] not in seen: + posts.append(p) + seen.add(p["url"]) + entry = {"fetched_on": today, "posts": posts, "summary": ""} + keyword_cache[key] = entry + if llm and entry and not entry.get("summary") and entry.get("posts"): + entry["summary"] = llm(query, entry["posts"]) or "" + keyword_cache[key] = entry + cached = entry["posts"] if entry else [] + pool[field] = [ + p for p in cached + if p.get("url") and p["url"] not in owner_urls + ] + + if same_day: + posts, shown = snap.get("posts") or [], shown + else: + posts, _, shown = pick_day(handle, today, queries, pool, shown) + wrote = { + "date": today, + "queries_hash": qhash, + "posts": posts, + "shown_urls": shown, + "summary": stitch_summary(queries, keyword_cache), + } + return wrote, wrote + + +_BRIEF = ( + "You write a 2-4 sentence briefing of what is currently happening in a topic, " + "using only the social posts below. No intro, no bullets, no URLs, no advice." +) + + +def summarize_keyword(keyword: str, posts: list[dict]) -> str: + excerpts = [p.get("excerpt") or "" for p in posts if p.get("excerpt")] + if not excerpts: + return "" + try: + client = config.get_llm_client() + resp = client.chat.completions.create( + model=config.OPENROUTER_MODEL, + temperature=0, + messages=[ + {"role": "system", "content": _BRIEF}, + { + "role": "user", + "content": f"Topic: {keyword}\n\nPosts:\n" + "\n---\n".join(excerpts[:12]), + }, + ], + ) + except Exception: + return "" + if not resp.choices: + return "" + return (resp.choices[0].message.content or "").strip() + + +def _extract_tweet(item: dict) -> dict | None: + tweet_id = str(item.get("id") or item.get("tweetId") or item.get("tweet_id") or "") + text = item.get("text") or item.get("fullText") or item.get("content") or "" + author = item.get("author") or item.get("user") or {} + username = ( + author.get("userName") or author.get("username") or author.get("screen_name") or + item.get("authorUserName") or item.get("username") or "" + ).lstrip("@") + url = item.get("url") or item.get("twitterUrl") or "" + if not url and tweet_id and username: + url = f"https://x.com/{username}/status/{tweet_id}" + if not url or not text: + return None + return { + "url": url, + "platform": "x", + "excerpt": text[:500], + "author": username, + "posted_at": item.get("createdAt") or item.get("created_at") or "", + } + + +def search_x_topic(query: str) -> list[dict]: + if not query or not config.APIFY_API_KEY: + return [] + try: + resp = httpx.post( + f"{_APIFY_BASE}/acts/{_ACTOR}/run-sync-get-dataset-items", + params={"token": config.APIFY_API_KEY, "timeout": 90, "memory": 256}, + json={"searchTerms": [query], "maxItems": 12, "queryType": "Latest", "lang": ""}, + timeout=120, + ) + resp.raise_for_status() + except httpx.HTTPError: + return [] + out = [] + for item in resp.json() or []: + post = _extract_tweet(item) + if post: + out.append(post) + return out + + +def _other_linkedin_samples(handle: str) -> list[WritingSample]: + samples = [] + for card in cards_service.list_published(): + if card.handle == handle: + continue + samples.extend(card.writing_samples or []) + return samples + + +def _load_keyword_cache(sb, keys: list[str]) -> dict: + if not keys: + return {} + resp = sb.table("keyword_posts").select("keyword,fetched_on,posts,summary").in_("keyword", keys).execute() + cache = {} + for row in resp.data or []: + cache[row["keyword"]] = { + "fetched_on": row.get("fetched_on"), + "posts": row.get("posts") or [], + "summary": row.get("summary") or "", + } + return cache + + +def _save_keyword_cache(sb, before: dict, after: dict) -> None: + for key, entry in after.items(): + if before.get(key) == entry: + continue + sb.table("keyword_posts").upsert( + { + "keyword": key, + "fetched_on": entry["fetched_on"], + "posts": entry["posts"], + "summary": entry.get("summary") or "", + }, + on_conflict="keyword", + ).execute() + + +def get_suggested_posts(handle: str, today: str | None = None) -> dict | None: + card = cards_service.get_card_by_handle(handle) + if not card: + return None + day = today or datetime.now(timezone.utc).date().isoformat() + sb = config.get_supabase() + resp = ( + sb.table("agent_profile_cards") + .select("suggested_posts") + .eq("handle", handle) + .execute() + ) + snap = (resp.data[0].get("suggested_posts") if resp.data else None) + keys = [keyword_key(q) for _, q in intent_queries(card) if q] + cache = _load_keyword_cache(sb, keys) + before = {k: dict(v) for k, v in cache.items()} + out, wrote = refresh_snapshot( + handle, + card, + snap, + day, + cache, + search_x_topic, + _other_linkedin_samples(handle), + llm=summarize_keyword, + ) + if wrote: + sb.table("agent_profile_cards").update({"suggested_posts": wrote}).eq("handle", handle).execute() + _save_keyword_cache(sb, before, cache) + return { + "handle": handle, + "date": out.get("date"), + "posts": out.get("posts") or [], + "summary": out.get("summary"), + } diff --git a/services/cards-api/tests/test_onboard_pipeline.py b/services/cards-api/tests/test_onboard_pipeline.py new file mode 100644 index 0000000..82d3bfb --- /dev/null +++ b/services/cards-api/tests/test_onboard_pipeline.py @@ -0,0 +1,44 @@ +""" +Onboarding pipeline: the profile URLs the user pasted (and that scraped fine) +end up as the card's links, not the LLM's rewritten versions of them. +Scrapers and synthesis are mocked. +""" +import asyncio + +from api import onboard as onboard_api +from models.card import CardSynthesis +from services.jobs import create_job, get_job + + +def test_scraped_profile_urls_override_llm_links(monkeypatch): + li_url = "https://www.linkedin.com/in/dilnawaz-hossain-asrafi-798a6a228/?utm_source=share&utm_medium=android_app" + x_url = "https://x.com/walker_nb" + + async def fake_fetch(url): + kind = onboard_api._classify_url(url) + return kind, f"{kind} profile text", None + + async def fake_github(handle): + return {"user": {"login": handle, "html_url": f"https://github.com/{handle}"}} + + # The LLM "tidies" every link it saw: drops LinkedIn's suffix, guesses the rest. + def fake_synthesize(*_a, **_k): + return CardSynthesis(identity={"name": "Dilnawaz", "links": { + "linkedin": "https://www.linkedin.com/in/dilnawaz-hossain-asrafi", + "github": "https://github.com/walkernullbyte", + "x": "https://x.com/walker", + }}) + + monkeypatch.setattr(onboard_api, "_safe_fetch_url", fake_fetch) + monkeypatch.setattr(onboard_api.github_scraper, "fetch_github", fake_github) + monkeypatch.setattr(onboard_api, "synthesize_card", fake_synthesize) + + job_id = create_job() + asyncio.run(onboard_api._run_pipeline(job_id, [li_url, "https://github.com/walker-null-byte", x_url], None)) + + job = get_job(job_id) + assert job.error is None + links = job.card.identity.links + assert links["linkedin"] == "https://www.linkedin.com/in/dilnawaz-hossain-asrafi-798a6a228/" + assert links["github"] == "https://github.com/walker-null-byte" + assert links["x"] == "https://x.com/walker_nb" diff --git a/services/cards-api/tests/test_publish_and_search.py b/services/cards-api/tests/test_publish_and_search.py index b89741c..3a7d6fe 100644 --- a/services/cards-api/tests/test_publish_and_search.py +++ b/services/cards-api/tests/test_publish_and_search.py @@ -38,7 +38,7 @@ def publish_client(monkeypatch): captured = {} def fake_insert(card, gh, x, raw, intent, owner_email, custom_handle, claim_token_hash=None, owner_user_id=None): - captured.update(owner_email=owner_email, claim_token_hash=claim_token_hash, owner_user_id=owner_user_id) + captured.update(card=card, owner_email=owner_email, claim_token_hash=claim_token_hash, owner_user_id=owner_user_id) return "alice" monkeypatch.setattr(onboard_api.cards_service, "insert_card", fake_insert) @@ -72,6 +72,33 @@ def test_publish_ignores_client_supplied_owner_email(publish_client): assert captured["claim_token_hash"] == cards_service.hash_claim_token(resp.json()["claim_token"]) +def test_publish_stores_calendly_and_google_calendar_links(publish_client): + client, captured = publish_client + body = { + "card": _card_json(), + "user_answers": { + "calendly_url": "calendly.com/alice", + "google_calendar_url": " https://calendar.app.google/abc123 ", + }, + } + + resp = client.post("/onboard/j1/publish", json=body) + + assert resp.status_code == 200 + assert captured["card"].calendly_url == "https://calendly.com/alice" # scheme added + assert captured["card"].google_calendar_url == "https://calendar.app.google/abc123" + assert resp.json()["google_calendar_url"] == "https://calendar.app.google/abc123" + + +def test_booking_links_drop_non_http_urls(): + card = AgentProfileCard.model_validate( + {**_card_json(), "calendly_url": "javascript://%0aalert(1)", "google_calendar_url": "ftp://calendar.google.com/x"} + ) + assert card.calendly_url is None + assert card.google_calendar_url is None + assert AgentProfileCard.model_validate({**_card_json(), "google_calendar_url": " "}).google_calendar_url is None + + def test_publish_takes_owner_from_the_session(publish_client): client, captured = publish_client body = {"card": _card_json(), "owner_email": "victim@example.com"} diff --git a/services/cards-api/tests/test_suggested_people.py b/services/cards-api/tests/test_suggested_people.py new file mode 100644 index 0000000..1ac5e20 --- /dev/null +++ b/services/cards-api/tests/test_suggested_people.py @@ -0,0 +1,295 @@ +import pytest + +from models.card import AgentProfileCard +from services import suggested_people as people_service + + +@pytest.fixture(autouse=True) +def _disable_quickenrich(monkeypatch): + monkeypatch.setattr(people_service.config, "QUICKENRICH_BASE_URL", "") + monkeypatch.setattr(people_service.config, "QUICKENRICH_API_KEY", "") + + +def _card(handle, **fields): + return AgentProfileCard( + id=handle, + status="published", + handle=handle, + identity={"name": handle.title(), "headline": "Engineer", "avatar_url": "avatar"}, + **fields, + ) + + +def test_matches_canonical_keywords_by_same_field(monkeypatch): + requester = _card( + "jane", + working_on=["Building with AI"], + can_help_with=["Code review"], + ) + candidates = [ + {"handle": "alex", "card": _card("alex", working_on=[" building with ai "]).model_dump()}, + {"handle": "sam", "card": _card("sam", can_help_with=["Code review"]).model_dump()}, + {"handle": "cross-field", "card": _card("cross-field", love_talking_about=["Building with AI"]).model_dump()}, + ] + monkeypatch.setattr(people_service.cards_service, "get_card_by_handle", lambda _h: requester) + monkeypatch.setattr(people_service.cards_service, "list_published_rows", lambda **_kw: candidates) + + result = people_service.get_suggested_people("jane") + + assert [p["handle"] for p in result["people"]] == ["alex", "sam"] + assert result["people"][0]["matched_fields"] == {"working_on": ["Building with AI"]} + assert result["people"][1]["matched_fields"] == {"can_help_with": ["Code review"]} + + +def test_legacy_aliases_map_to_canonical_keywords(monkeypatch): + requester = _card("jane", working_on=["Building with AI"]) + candidates = [{ + "handle": "alex", + "card": _card("alex", working_on=["developing AI agents"]).model_dump(), + }] + monkeypatch.setattr(people_service.cards_service, "get_card_by_handle", lambda _h: requester) + monkeypatch.setattr(people_service.cards_service, "list_published_rows", lambda **_kw: candidates) + + result = people_service.get_suggested_people("jane") + + assert result["people"][0]["matched_fields"] == {"working_on": ["Building with AI"]} + + +def test_aliases_are_field_aware(monkeypatch): + requester = _card("jane", working_on=["ai technology"]) + candidates = [{ + "handle": "alex", + "card": _card("alex", love_talking_about=["AI / ML"]).model_dump(), + }] + monkeypatch.setattr(people_service.cards_service, "get_card_by_handle", lambda _h: requester) + monkeypatch.setattr(people_service.cards_service, "list_published_rows", lambda **_kw: candidates) + + assert people_service.get_suggested_people("jane")["people"] == [] + + +def test_canonical_label_from_other_field_does_not_count(monkeypatch): + requester = _card("jane", working_on=["Design"]) + candidates = [{ + "handle": "alex", + "card": _card("alex", working_on=["Design"]).model_dump(), + }] + monkeypatch.setattr(people_service.cards_service, "get_card_by_handle", lambda _h: requester) + monkeypatch.setattr(people_service.cards_service, "list_published_rows", lambda **_kw: candidates) + + assert people_service.get_suggested_people("jane")["people"] == [] + + +def test_higher_score_beats_handle_sort(monkeypatch): + requester = _card("jane", working_on=["Building with AI"], can_help_with=["Code review"]) + candidates = [ + {"handle": "aaa", "card": _card("aaa", working_on=["Building with AI"]).model_dump()}, + {"handle": "zzz", "card": _card("zzz", working_on=["Building with AI"], can_help_with=["Code review"]).model_dump()}, + ] + monkeypatch.setattr(people_service.cards_service, "get_card_by_handle", lambda _h: requester) + monkeypatch.setattr(people_service.cards_service, "list_published_rows", lambda **_kw: candidates) + + result = people_service.get_suggested_people("jane") + + assert result["people"][0]["handle"] == "zzz" + assert result["people"][0]["match_score"] == 2 + + +def test_malformed_candidate_is_skipped(monkeypatch): + requester = _card("jane", working_on=["Building with AI"]) + candidates = [ + {"handle": "bad", "card": {"id": "bad", "working_on": [123]}}, + {"handle": "good", "card": _card("good", working_on=["Building with AI"]).model_dump()}, + ] + monkeypatch.setattr(people_service.cards_service, "get_card_by_handle", lambda _h: requester) + monkeypatch.setattr(people_service.cards_service, "list_published_rows", lambda **_kw: candidates) + + assert [p["handle"] for p in people_service.get_suggested_people("jane")["people"]] == ["good"] + + +def test_dedupes_matches_excludes_self_and_caps_at_two(monkeypatch): + requester = _card("jane", working_on=["Building with AI", "building with ai"]) + candidates = [ + {"handle": "jane", "card": requester.model_dump()}, + {"handle": "zed", "card": _card("zed", working_on=["Building with AI"]).model_dump()}, + {"handle": "amy", "card": _card("amy", working_on=["Building with AI"]).model_dump()}, + {"handle": "bob", "card": _card("bob", working_on=["Building with AI"]).model_dump()}, + ] + monkeypatch.setattr(people_service.cards_service, "get_card_by_handle", lambda _h: requester) + monkeypatch.setattr(people_service.cards_service, "list_published_rows", lambda **_kw: candidates) + + result = people_service.get_suggested_people("jane") + + assert [p["handle"] for p in result["people"]] == ["amy", "bob"] + assert result["people"][0]["match_score"] == 1 + + +def test_no_matches_and_unknown_interest_return_empty(monkeypatch): + requester = _card("jane", working_on=["unmapped free text"]) + monkeypatch.setattr(people_service.cards_service, "get_card_by_handle", lambda _h: requester) + monkeypatch.setattr( + people_service.cards_service, + "list_published_rows", + lambda **_kw: (_ for _ in ()).throw(AssertionError("should not scan without recognized interests")), + ) + + assert people_service.get_suggested_people("jane") == {"handle": "jane", "people": [], "outside": []} + + +def test_missing_card_returns_none(monkeypatch): + monkeypatch.setattr(people_service.cards_service, "get_card_by_handle", lambda _h: None) + assert people_service.get_suggested_people("missing") is None + + +def test_published_rows_loaded_with_only_card_and_handle(monkeypatch): + requester = _card("jane", working_on=["Building with AI"]) + seen = {} + + def list_rows(**kwargs): + seen.update(kwargs) + return [] + + monkeypatch.setattr(people_service.cards_service, "get_card_by_handle", lambda _h: requester) + monkeypatch.setattr(people_service.cards_service, "list_published_rows", list_rows) + + people_service.get_suggested_people("jane") + + assert seen == {"columns": "card,handle"} + + +def test_public_endpoint_returns_404_for_missing_card(monkeypatch): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from api import cards as cards_api + + monkeypatch.setattr(cards_api.cards_service, "get_card_by_handle", lambda _h: None) + app = FastAPI() + app.include_router(cards_api.router, prefix="/cards") + + response = TestClient(app).get("/cards/by-handle/missing/suggested-people") + + assert response.status_code == 404 + + +def test_public_endpoint_returns_people(monkeypatch): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from api import cards as cards_api + + monkeypatch.setattr( + people_service.cards_service, + "get_card_by_handle", + lambda _h: _card("jane", working_on=["Building with AI"]), + ) + monkeypatch.setattr( + people_service.cards_service, + "list_published_rows", + lambda **_kw: [{ + "handle": "alex", + "card": _card("alex", working_on=["Building with AI"]).model_dump(), + }], + ) + monkeypatch.setattr(people_service.config, "QUICKENRICH_BASE_URL", "") + monkeypatch.setattr(people_service.config, "QUICKENRICH_API_KEY", "") + app = FastAPI() + app.include_router(cards_api.router, prefix="/cards") + + response = TestClient(app).get("/cards/by-handle/jane/suggested-people") + + assert response.status_code == 200 + assert response.json()["people"][0]["handle"] == "alex" + assert response.json()["outside"] == [] + + +def test_outside_empty_when_quickenrich_not_configured(monkeypatch): + requester = _card("jane", connect_with=["Founders"]) + monkeypatch.setattr(people_service.cards_service, "get_card_by_handle", lambda _h: requester) + monkeypatch.setattr(people_service.cards_service, "list_published_rows", lambda **_kw: []) + monkeypatch.setattr(people_service, "search_outside", lambda _card, _rows: (_ for _ in ()).throw(AssertionError("should not search"))) + monkeypatch.setattr(people_service.config, "QUICKENRICH_BASE_URL", "") + monkeypatch.setattr(people_service.config, "QUICKENRICH_API_KEY", "") + + result = people_service.get_suggested_people("jane") + + assert result["outside"] == [] + + +def test_outside_drops_zynd_linkedin_and_caps_at_two(monkeypatch): + requester = _card("jane", connect_with=["Founders"]) + zynd = _card("alex", working_on=["Building with AI"]) + zynd.identity.links = {"linkedin": "https://www.linkedin.com/in/alex"} + monkeypatch.setattr(people_service.cards_service, "get_card_by_handle", lambda _h: requester) + monkeypatch.setattr( + people_service.cards_service, + "list_published_rows", + lambda **_kw: [{"handle": "alex", "card": zynd.model_dump()}], + ) + monkeypatch.setattr(people_service.config, "QUICKENRICH_BASE_URL", "https://qe.example") + monkeypatch.setattr(people_service.config, "QUICKENRICH_API_KEY", "k") + monkeypatch.setattr( + people_service, + "search_outside", + lambda _card, taken: [ + {"name": "Alex", "title": "Founder", "company": "Acme", "linkedin_url": "https://www.linkedin.com/in/alex"}, + {"name": "Pat", "title": "Founder", "company": "Beta", "linkedin_url": "https://linkedin.com/in/pat"}, + {"name": "Sam", "title": "Founder", "company": "Gamma", "linkedin_url": "https://linkedin.com/in/sam"}, + {"name": "NoUrl", "title": "Founder", "company": "Delta", "linkedin_url": ""}, + ], + ) + + result = people_service.get_suggested_people("jane") + + assert [p["linkedin_url"] for p in result["outside"]] == [ + "https://linkedin.com/in/pat", + "https://linkedin.com/in/sam", + ] + + +def test_outside_keeps_people_without_linkedin(monkeypatch): + requester = _card("jane", connect_with=["Founders"]) + monkeypatch.setattr(people_service.cards_service, "get_card_by_handle", lambda _h: requester) + monkeypatch.setattr(people_service.cards_service, "list_published_rows", lambda **_kw: []) + monkeypatch.setattr(people_service.config, "QUICKENRICH_BASE_URL", "https://qe.example") + monkeypatch.setattr(people_service.config, "QUICKENRICH_API_KEY", "k") + monkeypatch.setattr( + people_service, + "search_outside", + lambda _card, taken: [ + {"name": "Pat", "title": "Mentor", "company": "Acme", "linkedin_url": ""}, + ], + ) + + result = people_service.get_suggested_people("jane") + + assert result["outside"][0]["name"] == "Pat" + assert "email" not in result["outside"][0] + assert result["outside"][0]["source"] == "quickenrich" + + +def test_outside_search_bodies_titles_then_keywords_india_only(): + card = _card( + "jane", + connect_with=["Founders", "Engineers", "Investors"], + working_on=["Building with AI", "Open source", "B2B SaaS"], + love_talking_about=["Agentic AI"], + ) + bodies = people_service.outside_search_bodies(card) + assert len(bodies) == 2 + assert bodies[0]["title"]["include"] == ["Founder", "Investor"] + assert "bio_li" not in bodies[0] + assert bodies[1]["bio_li"]["include"] == ["AI", "open source"] + assert "title" not in bodies[1] + for body in bodies: + assert body["country_code"]["include"] == ["IN"] + assert body["per_page"] == 4 + assert "has_email" not in body + + +def test_outside_search_bodies_keywords_only_when_no_titles(): + card = _card("jane", working_on=["Building with AI"], connect_with=[]) + bodies = people_service.outside_search_bodies(card) + assert len(bodies) == 1 + assert bodies[0]["bio_li"]["include"] == ["AI"] + assert "title" not in bodies[0] + assert bodies[0]["country_code"]["include"] == ["IN"] diff --git a/services/cards-api/tests/test_suggested_posts.py b/services/cards-api/tests/test_suggested_posts.py new file mode 100644 index 0000000..aecfc32 --- /dev/null +++ b/services/cards-api/tests/test_suggested_posts.py @@ -0,0 +1,315 @@ +""" +Shared keyword cache + one post per section per handle/day. + +Run: python -m pytest tests/test_suggested_posts.py -v +""" +from models.card import AgentProfileCard, WritingSample +from services import suggested_posts as sp + + +def _card(**kwargs) -> AgentProfileCard: + defaults = dict( + id="testid", + status="published", + handle="jane", + working_on=["Building with AI"], + can_help_with=["Code review"], + connect_with=["Founders"], + love_talking_about=["Open source"], + ) + defaults.update(kwargs) + return AgentProfileCard(**defaults) + + +def _post(url, platform="x", excerpt="talking about AI today"): + return { + "url": url, + "platform": platform, + "excerpt": excerpt, + "author": "someone", + "posted_at": "2026-09-24", + } + + +def _pool_for(queries): + pool = {} + for i, (field, query) in enumerate(queries): + if not query: + continue + pool[field] = [ + _post(f"https://x.com/{field}/{n}", excerpt=query) for n in range(8) + ] + return pool + + +def test_intent_queries_uses_canonical_and_keeps_empty_slots(): + card = _card( + working_on=["building with ai", "Side project"], + can_help_with=[], + connect_with=[" Founders "], + ) + assert sp.intent_queries(card) == [ + ("working_on", "Building with AI"), + ("can_help_with", ""), + ("connect_with", "Founders"), + ("love_talking_about", "Open source"), + ] + + +def test_unknown_phrase_still_used(): + card = _card(working_on=["AI infra"]) + assert sp.intent_queries(card)[0] == ("working_on", "AI infra") + + +def test_same_handle_same_day_same_picks(): + q = sp.intent_queries(_card()) + pool = _pool_for(q) + a, _, _ = sp.pick_day("jane", "2026-09-24", q, pool, []) + b, _, _ = sp.pick_day("jane", "2026-09-24", q, pool, []) + assert [p["url"] if p else None for p in a] == [p["url"] if p else None for p in b] + + +def test_different_handles_same_keywords_different_picks(): + q = sp.intent_queries(_card()) + pool = _pool_for(q) + jane, _, _ = sp.pick_day("jane", "2026-09-24", q, pool, []) + bob, _, _ = sp.pick_day("bob", "2026-09-24", q, pool, []) + assert [p["url"] for p in jane if p] != [p["url"] for p in bob if p] + + +def test_empty_field_is_null_slot(): + q = sp.intent_queries(_card(can_help_with=[])) + posts, _, _ = sp.pick_day("jane", "2026-09-24", q, _pool_for(q), []) + assert posts[1] is None + assert posts[0]["field"] == "working_on" + + +def test_two_users_same_keyword_fetch_once(): + cache = {} + calls = [] + + def search_x(query): + calls.append(query) + return [_post(f"https://x.com/{query}/{i}", excerpt=query) for i in range(8)] + + jane = _card(handle="jane") + bob = _card(id="bobid", handle="bob") + sp.refresh_snapshot("jane", jane, None, "2026-09-24", cache, search_x, []) + sp.refresh_snapshot("bob", bob, None, "2026-09-24", cache, search_x, []) + assert len(calls) == 4 + assert set(calls) == {"Building with AI", "Code review", "Founders", "Open source"} + + +def test_snapshot_hit_skips_keyword_lookup_fetch(): + card = _card() + calls = {"n": 0} + + def search_x(query): + calls["n"] += 1 + return [] + + snap = { + "date": "2026-09-24", + "queries_hash": sp.queries_hash(sp.intent_queries(card)), + "posts": [{"field": "working_on", "url": "https://x.com/cached"}], + "shown_urls": ["https://x.com/cached"], + "summary": "cached briefing", + } + out, wrote = sp.refresh_snapshot("jane", card, snap, "2026-09-24", {}, search_x, []) + assert calls["n"] == 0 + assert wrote is None + assert out["posts"][0]["url"] == "https://x.com/cached" + + +def test_same_day_reuses_keyword_cache_without_refetch(): + cache = { + sp.keyword_key("Building with AI"): { + "fetched_on": "2026-09-24", + "posts": [_post(f"https://x.com/ai/{i}", excerpt="Building with AI") for i in range(8)], + }, + sp.keyword_key("Code review"): { + "fetched_on": "2026-09-24", + "posts": [_post(f"https://x.com/cr/{i}", excerpt="Code review") for i in range(8)], + }, + sp.keyword_key("Founders"): { + "fetched_on": "2026-09-24", + "posts": [_post(f"https://x.com/f/{i}", excerpt="Founders") for i in range(8)], + }, + sp.keyword_key("Open source"): { + "fetched_on": "2026-09-24", + "posts": [_post(f"https://x.com/os/{i}", excerpt="Open source") for i in range(8)], + }, + } + calls = {"n": 0} + + def search_x(query): + calls["n"] += 1 + return [] + + _, wrote = sp.refresh_snapshot("jane", _card(), None, "2026-09-24", cache, search_x, []) + assert calls["n"] == 0 + assert wrote is not None + assert len([p for p in wrote["posts"] if p]) == 4 + + +def test_new_day_refetches_stale_keywords_once(): + cache = { + sp.keyword_key("Building with AI"): { + "fetched_on": "2026-09-23", + "posts": [_post("https://x.com/old/ai")], + } + } + calls = [] + + def search_x(query): + calls.append(query) + return [_post(f"https://x.com/new/{query}", excerpt=query)] + + card = _card(can_help_with=[], connect_with=[], love_talking_about=[]) + sp.refresh_snapshot("jane", card, None, "2026-09-24", cache, search_x, []) + assert calls == ["Building with AI"] + assert cache[sp.keyword_key("Building with AI")]["fetched_on"] == "2026-09-24" + + +def test_next_day_skips_shown_urls(): + posts = [_post(f"https://x.com/ai/{i}", excerpt="Building with AI") for i in range(8)] + cache = { + sp.keyword_key("Building with AI"): { + "fetched_on": "2026-09-24", + "posts": posts, + } + } + + def search_x(query): + return posts + + card = _card(can_help_with=[], connect_with=[], love_talking_about=[]) + d1, wrote1 = sp.refresh_snapshot("jane", card, None, "2026-09-24", cache, search_x, []) + d2, wrote2 = sp.refresh_snapshot( + "jane", + card, + wrote1, + "2026-09-25", + cache, + search_x, + [], + ) + assert wrote2["posts"][0]["url"] != wrote1["posts"][0]["url"] + assert wrote1["posts"][0]["url"] in wrote2["shown_urls"] + + +def test_owner_writing_samples_excluded_from_linkedin_pool(): + owner = _card( + writing_samples=[ + WritingSample(platform="linkedin", excerpt="my own post about Building with AI", url="https://linkedin.com/feed/mine") + ] + ) + others = [ + WritingSample(platform="linkedin", excerpt="Building with AI at a startup", url="https://linkedin.com/feed/other"), + WritingSample(platform="x", excerpt="Building with AI tweet", url="https://x.com/other/1"), + ] + pool = sp.linkedin_candidates("Building with AI", others, exclude_urls={s.url for s in owner.writing_samples}) + urls = [p["url"] for p in pool] + assert "https://linkedin.com/feed/mine" not in urls + assert "https://linkedin.com/feed/other" in urls + assert "https://x.com/other/1" not in urls + + +def test_empty_fields_summary_is_null(): + card = _card(working_on=[], can_help_with=[], connect_with=[], love_talking_about=[]) + calls = [] + _, wrote = sp.refresh_snapshot( + "jane", card, None, "2026-09-24", {}, lambda q: [], [], llm=lambda k, p: calls.append(k) or "x" + ) + assert calls == [] + assert wrote["summary"] is None + + +def test_summary_is_topic_briefing_from_keyword_posts(): + cache = { + sp.keyword_key("Building with AI"): { + "fetched_on": "2026-09-24", + "posts": [_post("https://x.com/ai/1", excerpt="GPT-5 shipped to API")], + } + } + card = _card(can_help_with=[], connect_with=[], love_talking_about=[]) + _, wrote = sp.refresh_snapshot( + "jane", + card, + None, + "2026-09-24", + cache, + lambda q: [], + [], + llm=lambda keyword, posts: f"brief:{keyword}:{posts[0]['excerpt']}", + ) + assert wrote["summary"] == "Building with AI: brief:Building with AI:GPT-5 shipped to API" + + +def test_two_users_same_keyword_summarize_once(): + cache = {} + llm_calls = [] + + def search_x(query): + return [_post(f"https://x.com/{query}/1", excerpt=query)] + + def llm(keyword, posts): + llm_calls.append(keyword) + return f"news about {keyword}" + + jane = _card(can_help_with=[], connect_with=[], love_talking_about=[]) + bob = _card(id="bobid", handle="bob", can_help_with=[], connect_with=[], love_talking_about=[]) + sp.refresh_snapshot("jane", jane, None, "2026-09-24", cache, search_x, [], llm=llm) + sp.refresh_snapshot("bob", bob, None, "2026-09-24", cache, search_x, [], llm=llm) + assert llm_calls == ["Building with AI"] + + +def test_snapshot_hit_without_summary_backfills_from_keyword_cache(): + card = _card(can_help_with=[], connect_with=[], love_talking_about=[]) + cache = { + sp.keyword_key("Building with AI"): { + "fetched_on": "2026-09-24", + "posts": [_post("https://x.com/ai/1", excerpt="GPT-5 shipped")], + "summary": "", + } + } + snap = { + "date": "2026-09-24", + "queries_hash": sp.queries_hash(sp.intent_queries(card)), + "posts": [{"field": "working_on", "url": "https://x.com/ai/1"}], + "shown_urls": ["https://x.com/ai/1"], + "summary": None, + } + calls = [] + out, wrote = sp.refresh_snapshot( + "jane", + card, + snap, + "2026-09-24", + cache, + lambda q: calls.append("search") or [], + [], + llm=lambda k, p: f"brief:{k}", + ) + assert "search" not in calls + assert wrote is not None + assert wrote["posts"] == snap["posts"] + assert wrote["summary"] == "Building with AI: brief:Building with AI" + + +def test_snapshot_hit_keeps_stored_summary_without_llm(): + card = _card() + calls = [] + snap = { + "date": "2026-09-24", + "queries_hash": sp.queries_hash(sp.intent_queries(card)), + "posts": [{"field": "working_on", "url": "https://x.com/cached"}], + "shown_urls": ["https://x.com/cached"], + "summary": "cached briefing", + } + out, wrote = sp.refresh_snapshot( + "jane", card, snap, "2026-09-24", {}, lambda q: [], [], llm=lambda k, p: calls.append(k) + ) + assert wrote is None + assert calls == [] + assert out["summary"] == "cached briefing"