From 1c4cce8ebea9cde4e7595bf85e2daa06e35538a4 Mon Sep 17 00:00:00 2001 From: nbkdoesntknowcoding Date: Thu, 9 Jul 2026 18:47:16 +0530 Subject: [PATCH] =?UTF-8?q?feat(flows):=20community=20flows=20=E2=80=94=20?= =?UTF-8?q?publish=20/=20browse=20/=20import=20(core)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A shared, central catalog every Mnema instance (cloud + self-hosted) can browse and import flows from — one hub, many instances. This is the CORE client that ships to every self-hoster; the hub server itself is the hosted/EE piece and lives in the private ee overlay (not this repo). - routes/community.ts — browse/import/publish client - lib/community/ (hub-client, hub-config) + lib/flows/portability.ts + template-schema.ts — on publish, sanitize (drop doc_id/docs/target_folder_id); on import, rehydrate as unbound 'needs binding' nodes. No source content travels. - validate.ts draft mode tolerates imported-but-unbound nodes; publish is strict - web: /app/community browse + detail, Settings -> Community, publish from the flow header; COMMUNITY_HUB_URL/_ENABLED/_KEY env - schema: flow community slug + the (self-host-inert) hub tables; migrations 0071/0072 - auth: bypass session gate for /api/hub/* (served by the hub overlay when present) The EE hub/moderation routes (community-flows-hub.ts, admin/community.ts) are intentionally excluded from this core repo. Co-Authored-By: Claude Opus 4.8 Signed-off-by: nbkdoesntknowcoding --- .../migrations/0071_community_flows_hub.sql | 54 +++ .../migrations/0072_flow_community_slug.sql | 7 + apps/api/src/config/env.ts | 11 + apps/api/src/db/schema.ts | 65 ++++ apps/api/src/lib/community/hub-client.ts | 158 +++++++++ apps/api/src/lib/community/hub-config.ts | 29 ++ apps/api/src/lib/flows/community-e2e.test.ts | 78 +++++ apps/api/src/lib/flows/portability.test.ts | 236 +++++++++++++ apps/api/src/lib/flows/portability.ts | 251 +++++++++++++ apps/api/src/lib/flows/template-schema.ts | 131 +++++++ apps/api/src/lib/flows/validate.ts | 22 +- apps/api/src/plugins/auth.ts | 4 + apps/api/src/routes/community.ts | 330 ++++++++++++++++++ apps/api/src/routes/flows.ts | 3 + apps/api/src/server.ts | 2 + apps/web/src/components/app/Sidebar.tsx | 15 + .../components/community/CommunityBrowse.tsx | 183 ++++++++++ .../community/CommunityFlowDetail.tsx | 182 ++++++++++ apps/web/src/components/flows/FlowHeader.tsx | 121 ++++++- .../components/settings/CommunitySettings.tsx | 73 ++++ apps/web/src/layouts/SettingsLayout.astro | 1 + apps/web/src/pages/app/community/[slug].astro | 25 ++ apps/web/src/pages/app/community/index.astro | 24 ++ .../src/pages/app/settings/community.astro | 17 + docs/community-flows-e2e-runbook.md | 33 ++ docs/community-flows.md | 78 +++++ infra/.env.prod.example | 8 + 27 files changed, 2137 insertions(+), 4 deletions(-) create mode 100644 apps/api/drizzle/migrations/0071_community_flows_hub.sql create mode 100644 apps/api/drizzle/migrations/0072_flow_community_slug.sql create mode 100644 apps/api/src/lib/community/hub-client.ts create mode 100644 apps/api/src/lib/community/hub-config.ts create mode 100644 apps/api/src/lib/flows/community-e2e.test.ts create mode 100644 apps/api/src/lib/flows/portability.test.ts create mode 100644 apps/api/src/lib/flows/portability.ts create mode 100644 apps/api/src/lib/flows/template-schema.ts create mode 100644 apps/api/src/routes/community.ts create mode 100644 apps/web/src/components/community/CommunityBrowse.tsx create mode 100644 apps/web/src/components/community/CommunityFlowDetail.tsx create mode 100644 apps/web/src/components/settings/CommunitySettings.tsx create mode 100644 apps/web/src/pages/app/community/[slug].astro create mode 100644 apps/web/src/pages/app/community/index.astro create mode 100644 apps/web/src/pages/app/settings/community.astro create mode 100644 docs/community-flows-e2e-runbook.md create mode 100644 docs/community-flows.md diff --git a/apps/api/drizzle/migrations/0071_community_flows_hub.sql b/apps/api/drizzle/migrations/0071_community_flows_hub.sql new file mode 100644 index 000000000..0861e30c6 --- /dev/null +++ b/apps/api/drizzle/migrations/0071_community_flows_hub.sql @@ -0,0 +1,54 @@ +-- 0071 — Community Flows hub. A CENTRAL, cross-instance catalog of shareable +-- flow templates (n8n-style). Populated only on the SaaS hub (EE); inert empty +-- tables on self-host. Deliberately NOT workspace-scoped and NO RLS — the hub +-- serves cross-instance reads via the superuser db client (mirrors docs.public). +-- Idempotent (drizzle ledger is behind — apply via psql). + +CREATE TABLE IF NOT EXISTS "community_flows" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "slug" text NOT NULL, + "name" text NOT NULL, + "description" text, + "tags" text[] NOT NULL DEFAULT ARRAY[]::text[], + "template_json" jsonb NOT NULL, + "schema_version" integer NOT NULL DEFAULT 1, + "publisher_email" text NOT NULL, + "publisher_handle" text, + "source_version" text, + "node_count" integer NOT NULL DEFAULT 0, + "edge_count" integer NOT NULL DEFAULT 0, + "import_count" integer NOT NULL DEFAULT 0, + "status" text NOT NULL DEFAULT 'listed', + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now() +); + +DO $$ BEGIN + ALTER TABLE "community_flows" ADD CONSTRAINT "community_flows_slug_key" UNIQUE ("slug"); +EXCEPTION WHEN duplicate_table THEN NULL; WHEN duplicate_object THEN NULL; END $$; + +DO $$ BEGIN + ALTER TABLE "community_flows" + ADD CONSTRAINT "community_flows_publisher_name_key" UNIQUE ("publisher_email", "name"); +EXCEPTION WHEN duplicate_table THEN NULL; WHEN duplicate_object THEN NULL; END $$; + +CREATE INDEX IF NOT EXISTS "community_flows_status_idx" ON "community_flows" ("status"); +CREATE INDEX IF NOT EXISTS "community_flows_publisher_idx" ON "community_flows" ("publisher_email"); +CREATE INDEX IF NOT EXISTS "community_flows_popularity_idx" ON "community_flows" ("import_count"); +CREATE INDEX IF NOT EXISTS "community_flows_tags_gin" ON "community_flows" USING gin ("tags"); + +CREATE TABLE IF NOT EXISTS "community_flow_reports" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "community_flow_id" uuid NOT NULL REFERENCES "community_flows"("id") ON DELETE CASCADE, + "reason" text NOT NULL, + "detail" text, + "reporter_fingerprint" text, + "created_at" timestamptz NOT NULL DEFAULT now(), + "resolved_at" timestamptz, + "resolved_by" text +); + +CREATE INDEX IF NOT EXISTS "community_flow_reports_flow_idx" + ON "community_flow_reports" ("community_flow_id"); +CREATE INDEX IF NOT EXISTS "community_flow_reports_unresolved_idx" + ON "community_flow_reports" ("resolved_at"); diff --git a/apps/api/drizzle/migrations/0072_flow_community_slug.sql b/apps/api/drizzle/migrations/0072_flow_community_slug.sql new file mode 100644 index 000000000..8f4d9f089 --- /dev/null +++ b/apps/api/drizzle/migrations/0072_flow_community_slug.sql @@ -0,0 +1,7 @@ +-- 0072 — Community Flows: track whether a local flow is published to the hub. +-- When a workspace publishes a flow to the community, we store the returned hub +-- slug here so the UI can show "published to community" state + an unpublish +-- affordance, and badge the flow on the list page. Core migration (flows is a +-- core table) — applies on self-host too. Idempotent (apply via psql). + +ALTER TABLE "flows" ADD COLUMN IF NOT EXISTS "community_slug" text; diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 617d5333e..cb561fd7e 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -244,6 +244,17 @@ const envSchema = z.object({ // Ed25519 private signing key (PEM) — ISSUING SIDE ONLY (cloud). Absent on // self-host, which only verifies against the baked-in public key. LICENSE_SIGNING_KEY: z.string().optional(), + + // Community Flows hub (open-core). Every instance — cloud AND self-hosted — + // talks to a CENTRAL hub to browse/import/publish flow templates. Defaults to + // the SaaS host; self-hosters may point elsewhere or disable entirely. + // URL — base URL of the hub API (serves /api/hub/*). + // ENABLED — false fully disables the feature (air-gapped self-host). + // KEY — the instance's community-license key, used only to authenticate + // PUBLISH calls. Browse/import need no key. Absent = publish off. + COMMUNITY_HUB_URL: z.string().url().default('https://api.theboringpeople.in'), + COMMUNITY_HUB_ENABLED: z.enum(['true', 'false']).default('true').transform((v) => v === 'true'), + COMMUNITY_HUB_KEY: z.string().min(1).optional(), }); const parsed = envSchema diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 55524b52c..afdb55881 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -961,6 +961,9 @@ export const flows = pgTable( // the link can view the published flow read-only. shareToken: text('share_token'), sharedAt: timestamp('shared_at', { withTimezone: true }), + // Community Flows (0072) — set to the hub listing slug when this flow has + // been published to the community hub; null when not published. + communitySlug: text('community_slug'), }, (table) => ({ workspaceSlugUnique: unique('flows_workspace_id_slug_key').on(table.workspaceId, table.slug), @@ -1801,6 +1804,68 @@ export const communityRegistrations = pgTable('community_registrations', { createdAt: timestamp('created_at', { withTimezone: true }).notNull().default(sql`now()`), }); +/* ── Community Flows hub (migration 0071) ───────────────────────────────────── + * CENTRAL, cross-instance catalog of shareable flow templates. Unlike almost + * everything else, these tables are NOT workspace-scoped and carry NO RLS — the + * hub is cross-instance by design and is read via the superuser `db` client + * (mirrors the /api/docs/public pattern). Populated only on the SaaS hub (EE); + * inert empty tables on self-host installs. template_json holds the portable + * PortableFlowTemplate (lib/flows/portability.ts) with all workspace ids stripped. */ +export const communityFlows = pgTable( + 'community_flows', + { + id: uuid('id').primaryKey().default(sql`gen_random_uuid()`), + slug: text('slug').notNull().unique(), + name: text('name').notNull(), + description: text('description'), + tags: text('tags').array().notNull().default(sql`ARRAY[]::text[]`), + templateJson: jsonb('template_json').notNull(), + schemaVersion: integer('schema_version').notNull().default(1), + // Publisher identity derived from the validated community-license key. + publisherEmail: text('publisher_email').notNull(), + publisherHandle: text('publisher_handle'), + sourceVersion: text('source_version'), + nodeCount: integer('node_count').notNull().default(0), + edgeCount: integer('edge_count').notNull().default(0), + importCount: integer('import_count').notNull().default(0), + // listed | unlisted | removed + status: text('status').notNull().default('listed'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + statusIdx: index('community_flows_status_idx').on(table.status), + publisherIdx: index('community_flows_publisher_idx').on(table.publisherEmail), + popularityIdx: index('community_flows_popularity_idx').on(table.importCount), + // one listing per (publisher, name) — re-publishing updates in place. + publisherNameUnique: unique('community_flows_publisher_name_key').on( + table.publisherEmail, + table.name, + ), + }), +); + +export const communityFlowReports = pgTable( + 'community_flow_reports', + { + id: uuid('id').primaryKey().default(sql`gen_random_uuid()`), + communityFlowId: uuid('community_flow_id') + .notNull() + .references(() => communityFlows.id, { onDelete: 'cascade' }), + // spam | malicious | inappropriate | broken | other + reason: text('reason').notNull(), + detail: text('detail'), + reporterFingerprint: text('reporter_fingerprint'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + resolvedAt: timestamp('resolved_at', { withTimezone: true }), + resolvedBy: text('resolved_by'), + }, + (table) => ({ + flowIdx: index('community_flow_reports_flow_idx').on(table.communityFlowId), + unresolvedIdx: index('community_flow_reports_unresolved_idx').on(table.resolvedAt), + }), +); + // ── Internal admin center (migration 0047) ────────────────────────────────────── /** A license: a plan tier + seats + entitlements, optionally bound to a workspace diff --git a/apps/api/src/lib/community/hub-client.ts b/apps/api/src/lib/community/hub-client.ts new file mode 100644 index 000000000..6a7635d66 --- /dev/null +++ b/apps/api/src/lib/community/hub-client.ts @@ -0,0 +1,158 @@ +/** + * Community hub client (Community Flows — Phase 2, P2-1). + * + * The CORE-side HTTP client every instance uses to talk to the central hub + * (COMMUNITY_HUB_URL). This is what makes a self-hosted install participate in + * the same catalog as the cloud. Reads retry on transient errors; writes never + * retry (publish must not double-post). When the feature is disabled, every + * call throws CommunityDisabledError so routes can translate to 404. + */ +import { getHubBaseUrl, getHubKey, isCommunityEnabled } from './hub-config.js'; +import { config } from '../../config/env.js'; + +export class CommunityDisabledError extends Error { + constructor() { + super('The community hub is disabled on this instance.'); + this.name = 'CommunityDisabledError'; + } +} + +export class HubError extends Error { + constructor( + public status: number, + public code: string, + message: string, + public body?: unknown, + ) { + super(message); + this.name = 'HubError'; + } +} + +const TIMEOUT_MS = 10_000; + +interface FetchOpts { + method?: string; + body?: unknown; + key?: string; + retries?: number; +} + +async function hubFetch(path: string, opts: FetchOpts = {}): Promise { + if (!isCommunityEnabled()) throw new CommunityDisabledError(); + const url = `${getHubBaseUrl()}${path}`; + const method = opts.method ?? 'GET'; + const retries = opts.retries ?? (method === 'GET' ? 2 : 0); + + let lastErr: unknown; + for (let attempt = 0; attempt <= retries; attempt++) { + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), TIMEOUT_MS); + try { + const res = await fetch(url, { + method, + headers: { + 'content-type': 'application/json', + ...(opts.key ? { authorization: `Bearer ${opts.key}` } : {}), + 'x-mnema-instance': config.WEB_BASE_URL, + }, + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, + signal: ac.signal, + }); + clearTimeout(timer); + + const text = await res.text(); + const parsed = text ? (JSON.parse(text) as unknown) : null; + if (!res.ok) { + // Retry 5xx on reads only; surface 4xx immediately. + if (res.status >= 500 && attempt < retries) { + lastErr = new HubError(res.status, 'hub_5xx', `Hub returned ${res.status}`, parsed); + continue; + } + const code = (parsed as { error?: string })?.error ?? `http_${res.status}`; + throw new HubError(res.status, code, `Hub error ${res.status}`, parsed); + } + return parsed as T; + } catch (err) { + clearTimeout(timer); + if (err instanceof HubError) throw err; + // Network/abort — retry on reads. + lastErr = err; + if (attempt >= retries) throw new HubError(502, 'hub_unreachable', 'Community hub is unreachable.', String(err)); + } + } + throw lastErr instanceof Error ? lastErr : new HubError(502, 'hub_unreachable', 'Community hub is unreachable.'); +} + +export interface HubListItem { + slug: string; + name: string; + description: string | null; + tags: string[]; + nodeCount: number; + importCount: number; + publisherHandle: string | null; + createdAt: string; +} + +export interface HubListResult { + flows: HubListItem[]; + next_cursor: number | null; +} + +export interface HubFlowDetail { + slug: string; + name: string; + description: string | null; + tags: string[]; + schema_version: number; + template_json: unknown; + node_count: number; + edge_count: number; + import_count: number; + publisher_handle: string | null; + created_at: string; +} + +export function hubList(params: { + q?: string; + tag?: string; + sort?: string; + cursor?: number; + limit?: number; +}): Promise { + const qs = new URLSearchParams(); + if (params.q) qs.set('q', params.q); + if (params.tag) qs.set('tag', params.tag); + if (params.sort) qs.set('sort', params.sort); + if (params.cursor) qs.set('cursor', String(params.cursor)); + if (params.limit) qs.set('limit', String(params.limit)); + const suffix = qs.toString(); + return hubFetch(`/api/hub/flows${suffix ? `?${suffix}` : ''}`); +} + +export function hubGet(slug: string): Promise { + return hubFetch(`/api/hub/flows/${encodeURIComponent(slug)}`); +} + +export function hubPublish( + body: { template_json: unknown; name: string; description: string | null; tags: string[]; source_version?: string }, +): Promise<{ slug: string; url: string }> { + const key = getHubKey(); + if (!key) throw new HubError(400, 'no_key', 'No community key configured on this instance.'); + return hubFetch(`/api/hub/flows`, { method: 'POST', body, key }); +} + +export function hubUnpublish(slug: string): Promise<{ removed: boolean }> { + const key = getHubKey(); + if (!key) throw new HubError(400, 'no_key', 'No community key configured on this instance.'); + return hubFetch(`/api/hub/flows/${encodeURIComponent(slug)}`, { method: 'DELETE', key }); +} + +export function hubReport(slug: string, body: { reason: string; detail?: string }): Promise<{ received: boolean }> { + return hubFetch(`/api/hub/flows/${encodeURIComponent(slug)}/report`, { method: 'POST', body }); +} + +export function hubBumpImport(slug: string): Promise<{ ok: boolean }> { + return hubFetch(`/api/hub/flows/${encodeURIComponent(slug)}/imported`, { method: 'POST', retries: 0 }); +} diff --git a/apps/api/src/lib/community/hub-config.ts b/apps/api/src/lib/community/hub-config.ts new file mode 100644 index 000000000..e23f2d3fb --- /dev/null +++ b/apps/api/src/lib/community/hub-config.ts @@ -0,0 +1,29 @@ +/** + * Community hub configuration helpers (Community Flows — Phase 0, P0-4). + * + * A thin typed surface over the COMMUNITY_HUB_* env so the rest of the code + * never reads process.env directly. Shared by the hub client (P2-1), the proxy + * routes (P2-2), and the publish route (P2-4). + */ +import { config } from '../../config/env.js'; + +/** Whether the community feature is enabled on this instance at all. */ +export function isCommunityEnabled(): boolean { + return config.COMMUNITY_HUB_ENABLED; +} + +/** Base URL of the central hub (no trailing slash). */ +export function getHubBaseUrl(): string { + return config.COMMUNITY_HUB_URL.replace(/\/+$/, ''); +} + +/** This instance's community-license key, used to authenticate publishes. + * Undefined when unset — publish is disabled with a clear message. */ +export function getHubKey(): string | undefined { + return config.COMMUNITY_HUB_KEY; +} + +/** True when this instance can publish (feature on + a key configured). */ +export function canPublishToCommunity(): boolean { + return isCommunityEnabled() && !!getHubKey(); +} diff --git a/apps/api/src/lib/flows/community-e2e.test.ts b/apps/api/src/lib/flows/community-e2e.test.ts new file mode 100644 index 000000000..b819b29b5 --- /dev/null +++ b/apps/api/src/lib/flows/community-e2e.test.ts @@ -0,0 +1,78 @@ +/** + * Community Flows — publish→import boundary test (Phase 4, P4-4). + * + * Simulates the full cross-workspace / cross-instance loop at the data boundary, + * without live HTTP: + * Workspace A flow → sanitize (publish) → validateTemplate (hub ingest) → + * rehydrate (import into workspace B) → re-bind a doc locally. + * + * The hard gate is the LEAK ASSERTION: nothing workspace-A-scoped may exist in + * the object the hub would store. See docs/community-flows.md for the manual + * two-instance runbook that complements this. + */ +import { describe, it, expect } from 'vitest'; +import type { FlowNode, FlowEdge } from './validate.js'; +import { validateFlow } from './validate.js'; +import { sanitizeFlowForPublish, rehydrateFlowFromTemplate } from './portability.js'; +import { validateTemplate } from './template-schema.js'; + +const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; + +// Workspace A resources (must never appear downstream). +const A_DOC = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const A_FOLDER = 'ffffffff-ffff-4fff-8fff-ffffffffffff'; +// Workspace B resource the importer binds to. +const B_DOC = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; + +function workspaceAFlow(): { nodes: FlowNode[]; edges: FlowEdge[] } { + return { + nodes: [ + { client_node_id: 'start', kind: 'instruction', title: 'Start', position_x: 0, position_y: 0, data: { text: 'Begin the review.' } }, + { client_node_id: 'guide', kind: 'doc', title: 'Style guide', position_x: 0, position_y: 100, data: { doc_id: A_DOC, instruction: 'Apply the style guide.' } }, + { client_node_id: 'write', kind: 'capture', title: 'Write notes', position_x: 0, position_y: 200, data: { title_hint: 'Notes', instruction: 'Capture findings.', target_folder_id: A_FOLDER } }, + ], + edges: [ + { from_node_id: 'start', to_node_id: 'guide', from_socket: 'default' }, + { from_node_id: 'guide', to_node_id: 'write', from_socket: 'default' }, + ], + }; +} + +describe('community publish → import boundary', () => { + it('runs the full loop without leaking workspace-A ids, and imports as a valid draft', () => { + const a = workspaceAFlow(); + + // 1. Workspace A publishes: sanitize into a portable template. + const template = sanitizeFlowForPublish(a.nodes, a.edges, { name: 'Review flow', description: 'A review', tags: ['review'] }); + + // 2. Hub ingest boundary: the template must validate... + const ingest = validateTemplate(template); + expect(ingest.ok).toBe(true); + + // ...and the object the hub would STORE must contain no workspace-A secrets. + const stored = JSON.stringify(template); + expect(stored).not.toMatch(UUID_RE); + expect(stored).not.toContain(A_DOC); + expect(stored).not.toContain(A_FOLDER); + + // 3. Workspace B imports: rehydrate into local draft rows. + const imported = rehydrateFlowFromTemplate(template); + expect(imported.nodes.map((n) => n.client_node_id).sort()).toEqual(['guide', 'start', 'write']); + // doc + capture are flagged unbound; instruction is not. + expect(imported.unboundNodes.map((u) => u.clientNodeId).sort()).toEqual(['guide', 'write']); + + // 4. The imported draft is legal in DRAFT mode (pending bindings) ... + expect(validateFlow(imported.nodes, imported.edges, { mode: 'draft' }).valid).toBe(true); + // ... but NOT publishable until bound. + expect(validateFlow(imported.nodes, imported.edges, { mode: 'publish' }).valid).toBe(false); + + // 5. Workspace B binds the doc node to its OWN doc, then it validates for publish. + const bound = imported.nodes.map((n) => + n.client_node_id === 'guide' + ? { ...n, data: { instruction: n.data.instruction, doc_id: B_DOC } } + : n, + ); + // capture's folder binding is optional, so binding just the doc is enough to publish. + expect(validateFlow(bound, imported.edges, { mode: 'publish' }).valid).toBe(true); + }); +}); diff --git a/apps/api/src/lib/flows/portability.test.ts b/apps/api/src/lib/flows/portability.test.ts new file mode 100644 index 000000000..86f503fa8 --- /dev/null +++ b/apps/api/src/lib/flows/portability.test.ts @@ -0,0 +1,236 @@ +/** + * Portability lib tests (Community Flows — Phase 0, P0-5). + * + * Guards the two promises of the feature: + * 1. Round-trip topology survives publish → import. + * 2. LEAK GUARD — no workspace UUIDs (doc_id / doc_ids / target_folder_id / + * any workspace uuid) ever escape into a published template. + */ +import { describe, it, expect } from 'vitest'; +import type { FlowNode, FlowEdge } from './validate.js'; +import { + sanitizeFlowForPublish, + rehydrateFlowFromTemplate, + SCHEMA_VERSION, + type PortableFlowTemplate, +} from './portability.js'; +import { validateTemplate, MAX_TEMPLATE_BYTES } from './template-schema.js'; + +const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; + +const DOC_A = '11111111-1111-4111-8111-111111111111'; +const DOC_B = '22222222-2222-4222-8222-222222222222'; +const FOLDER = '33333333-3333-4333-8333-333333333333'; + +/** A fixture flow exercising every node kind + several edges. */ +function fixtureNodes(): FlowNode[] { + return [ + { + client_node_id: 'start', + kind: 'instruction', + title: 'Kickoff', + position_x: 0, + position_y: 0, + data: { text: 'Read the brief and begin.' }, + }, + { + client_node_id: 'ref-doc', + kind: 'doc', + title: 'Style guide', + position_x: 100, + position_y: 0, + data: { doc_id: DOC_A, instruction: 'Follow this style guide.' }, + }, + { + client_node_id: 'ref-docs', + kind: 'docs', + title: 'Specs', + position_x: 200, + position_y: 0, + data: { doc_ids: [DOC_A, DOC_B], filter: { type: 'spec' }, instruction: 'Cross-check the specs.' }, + }, + { + client_node_id: 'branch', + kind: 'decision', + title: 'Approved?', + position_x: 300, + position_y: 0, + data: { question: 'Is it approved?', branches: { yes: null, no: null }, default_branch: 'no' }, + }, + { + client_node_id: 'write-out', + kind: 'capture', + title: 'Write summary', + position_x: 400, + position_y: 0, + data: { title_hint: 'Summary', instruction: 'Write a summary doc.', target_folder_id: FOLDER, autonomous: true }, + }, + ]; +} + +function fixtureEdges(): FlowEdge[] { + return [ + { from_node_id: 'start', to_node_id: 'ref-doc', from_socket: 'default' }, + { from_node_id: 'ref-doc', to_node_id: 'ref-docs', from_socket: 'default' }, + { from_node_id: 'ref-docs', to_node_id: 'branch', from_socket: 'default' }, + { from_node_id: 'branch', to_node_id: 'write-out', from_socket: 'yes' }, + ]; +} + +const META = { name: 'Review flow', description: 'A review pipeline', tags: ['review', 'qa'] }; + +describe('sanitizeFlowForPublish', () => { + it('leak guard: no UUID appears anywhere in the published template', () => { + const template = sanitizeFlowForPublish(fixtureNodes(), fixtureEdges(), META); + const serialized = JSON.stringify(template); + expect(serialized).not.toMatch(UUID_RE); + // Explicitly assert the known secrets are gone. + expect(serialized).not.toContain(DOC_A); + expect(serialized).not.toContain(DOC_B); + expect(serialized).not.toContain(FOLDER); + }); + + it('does not carry doc content keys (markdown/body) — only intent', () => { + const nodes = fixtureNodes(); + // Pretend an upstream bug attached content; sanitize must not forward it. + (nodes[1] as FlowNode).data.markdown = 'SECRET internal content'; + const template = sanitizeFlowForPublish(nodes, fixtureEdges(), META); + expect(JSON.stringify(template)).not.toContain('SECRET internal content'); + }); + + it('marks doc/docs/capture with requiresBinding; leaves instruction/decision clean', () => { + const template = sanitizeFlowForPublish(fixtureNodes(), fixtureEdges(), META); + const byId = Object.fromEntries(template.nodes.map((n) => [n.clientNodeId, n])); + expect(byId['ref-doc']!.data.requiresBinding).toBe('doc'); + expect(byId['ref-docs']!.data.requiresBinding).toBe('docs'); + expect(byId['write-out']!.data.requiresBinding).toBe('capture-folder'); + expect(byId['start']!.data.requiresBinding).toBeUndefined(); + expect(byId['branch']!.data.requiresBinding).toBeUndefined(); + }); + + it('docs node keeps filter.type but drops doc_ids', () => { + const template = sanitizeFlowForPublish(fixtureNodes(), fixtureEdges(), META); + const docs = template.nodes.find((n) => n.clientNodeId === 'ref-docs')!; + expect(docs.data.doc_ids).toBeUndefined(); + expect(docs.data.filter).toEqual({ type: 'spec' }); + expect(docs.data.instruction).toBe('Cross-check the specs.'); + }); + + it('is deterministic: identical input yields byte-identical output', () => { + const a = JSON.stringify(sanitizeFlowForPublish(fixtureNodes(), fixtureEdges(), META)); + const b = JSON.stringify(sanitizeFlowForPublish(fixtureNodes(), fixtureEdges(), META)); + expect(a).toBe(b); + }); + + it('defensively strips ids from unknown/future node kinds', () => { + const rogue: FlowNode = { + client_node_id: 'rogue', + kind: 'doc', // typed as doc but we hand it future-ish extra data + title: 'x', + position_x: 0, + position_y: 0, + data: { doc_id: DOC_A, secret_id: DOC_B, nested: { folder_id: FOLDER, keep: 'ok' } }, + }; + // Force the default branch via an unknown kind cast. + const asUnknown = { ...rogue, kind: 'webhook' as unknown as FlowNode['kind'] }; + const template = sanitizeFlowForPublish([asUnknown], [], META); + const serialized = JSON.stringify(template); + expect(serialized).not.toMatch(UUID_RE); + expect(serialized).toContain('keep'); + }); +}); + +describe('rehydrateFlowFromTemplate', () => { + it('round-trips topology: nodes, edges, positions, ids preserved', () => { + const template = sanitizeFlowForPublish(fixtureNodes(), fixtureEdges(), META); + const out = rehydrateFlowFromTemplate(template); + + expect(out.name).toBe(META.name); + expect(out.nodes.map((n) => n.client_node_id).sort()).toEqual( + fixtureNodes().map((n) => n.client_node_id).sort(), + ); + expect(out.edges).toHaveLength(fixtureEdges().length); + const start = out.nodes.find((n) => n.client_node_id === 'start')!; + expect(start.position_x).toBe(0); + expect(start.data.text).toBe('Read the brief and begin.'); + }); + + it('reports doc/docs/capture as unbound; not instruction/decision', () => { + const template = sanitizeFlowForPublish(fixtureNodes(), fixtureEdges(), META); + const out = rehydrateFlowFromTemplate(template); + const unbound = out.unboundNodes.map((u) => u.clientNodeId).sort(); + expect(unbound).toEqual(['ref-doc', 'ref-docs', 'write-out']); + expect(out.unboundNodes.find((u) => u.clientNodeId === 'ref-doc')!.requiresBinding).toBe('doc'); + }); + + it('rehydrated nodes carry no workspace ids', () => { + const template = sanitizeFlowForPublish(fixtureNodes(), fixtureEdges(), META); + const out = rehydrateFlowFromTemplate(template); + for (const n of out.nodes) { + expect(n.data.doc_id).toBeUndefined(); + expect(n.data.doc_ids).toBeUndefined(); + expect(n.data.target_folder_id).toBeUndefined(); + } + }); + + it('rejects an unsupported schemaVersion', () => { + const template = sanitizeFlowForPublish(fixtureNodes(), fixtureEdges(), META); + const bumped: PortableFlowTemplate = { ...template, schemaVersion: SCHEMA_VERSION + 1 }; + expect(() => rehydrateFlowFromTemplate(bumped)).toThrow(/Unsupported/i); + }); +}); + +describe('validateTemplate', () => { + const good = () => sanitizeFlowForPublish(fixtureNodes(), fixtureEdges(), META); + + it('accepts a sanitized template', () => { + const res = validateTemplate(good()); + expect(res.ok).toBe(true); + }); + + it('rejects a UUID smuggled into node data', () => { + const t = good(); + t.nodes[0]!.data.sneaky = DOC_A; + const res = validateTemplate(t); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.errors.some((e) => e.code === 'uuid_in_node_data')).toBe(true); + }); + + it('rejects an edge referencing a missing node', () => { + const t = good(); + t.edges.push({ fromNodeId: 'start', toNodeId: 'ghost', fromSocket: 'default' }); + const res = validateTemplate(t); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.errors.some((e) => e.code === 'edge_unknown_node')).toBe(true); + }); + + it('rejects duplicate clientNodeId', () => { + const t = good(); + t.nodes.push({ ...t.nodes[0]! }); + const res = validateTemplate(t); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.errors.some((e) => e.code === 'duplicate_client_node_id')).toBe(true); + }); + + it('rejects an unsupported schemaVersion', () => { + const t = good(); + t.schemaVersion = SCHEMA_VERSION + 1; + const res = validateTemplate(t); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.errors.some((e) => e.code === 'unsupported_schema_version')).toBe(true); + }); + + it('rejects an oversize template', () => { + const t = good(); + t.nodes[0]!.data.text = 'x'.repeat(MAX_TEMPLATE_BYTES + 1); + const res = validateTemplate(t); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.errors.some((e) => e.code === 'oversize')).toBe(true); + }); + + it('rejects a structurally malformed input', () => { + const res = validateTemplate({ schemaVersion: 1, name: '', nodes: [], edges: [] }); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.errors[0]!.code).toBe('schema_shape'); + }); +}); diff --git a/apps/api/src/lib/flows/portability.ts b/apps/api/src/lib/flows/portability.ts new file mode 100644 index 000000000..5bfc57d0a --- /dev/null +++ b/apps/api/src/lib/flows/portability.ts @@ -0,0 +1,251 @@ +/** + * Flow portability (Community Flows — Phase 0, tasks P0-1 / P0-2). + * + * `sanitizeFlowForPublish` converts a workspace-bound flow graph into a + * portable template that carries NO workspace-scoped IDs and NO private + * content, so it can be published to the community hub and imported into any + * other workspace — or any other Mnema instance. + * + * `rehydrateFlowFromTemplate` is the inverse: it turns a template back into the + * node/edge rows for a new *local* draft flow, leaving workspace bindings + * (doc_id / doc_ids / target_folder_id) empty for the importer to re-bind. + * + * Privacy guarantee: referenced doc/folder CONTENT never travels — only a + * node's structural intent (title + instruction). Enforced by dropping every + * workspace id and asserted by the leak-guard test (P0-5). + * + * Unbound nodes carry a `data.requiresBinding` marker (one of BindingKind). + * That marker doubles as (a) the UI "needs binding" signal and (b) the future + * draft-tolerant validation signal — a doc/docs/capture node with a + * requiresBinding marker and no id is a legal DRAFT, but must be resolved + * before publish. See validate.ts for the enforcement side (Phase 2). + */ +import type { FlowNode, FlowEdge, FlowNodeKind } from './validate.js'; + +/** Current portable-template wire version. Bump on any breaking format change. */ +export const SCHEMA_VERSION = 1; + +/** Which local resource an imported node must be bound to before publish. */ +export type BindingKind = 'doc' | 'docs' | 'capture-folder'; + +export interface PortableNode { + clientNodeId: string; + kind: FlowNodeKind; + title: string; + positionX: number; + positionY: number; + /** Portable, workspace-free node config. Carries `requiresBinding` when the + * node needs a local resource re-bound after import. */ + data: Record; +} + +export interface PortableEdge { + fromNodeId: string; + toNodeId: string; + fromSocket: string; +} + +export interface PortableFlowTemplate { + schemaVersion: number; + name: string; + description: string | null; + tags: string[]; + nodes: PortableNode[]; + edges: PortableEdge[]; +} + +export interface FlowTemplateMeta { + name: string; + description?: string | null; + tags?: string[]; +} + +export interface UnboundNode { + clientNodeId: string; + kind: FlowNodeKind; + requiresBinding: BindingKind; +} + +export interface RehydratedFlow { + name: string; + description: string | null; + /** Node rows ready to insert into a new local draft version (snake_case to + * match the flows lib / DB mapping). */ + nodes: FlowNode[]; + edges: FlowEdge[]; + /** Nodes whose workspace binding was stripped and must be re-bound locally. */ + unboundNodes: UnboundNode[]; +} + +const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; + +function looksLikeUuid(v: unknown): boolean { + return typeof v === 'string' && UUID_RE.test(v); +} + +/** Deep-clone a value with a stable (sorted) key order so sanitize output is + * byte-deterministic regardless of input key ordering. */ +function canonical(value: T): T { + if (Array.isArray(value)) return value.map((v) => canonical(v)) as unknown as T; + if (value && typeof value === 'object') { + const out: Record = {}; + for (const key of Object.keys(value as Record).sort()) { + out[key] = canonical((value as Record)[key]); + } + return out as T; + } + return value; +} + +/** Defensive backstop for unknown node kinds: drop any key ending in _id/_ids + * and any UUID-valued string, recursively. Known kinds are handled explicitly + * below and never reach this. */ +function stripWorkspaceIds(value: unknown): unknown { + if (Array.isArray(value)) return value.filter((v) => !looksLikeUuid(v)).map(stripWorkspaceIds); + if (value && typeof value === 'object') { + const out: Record = {}; + for (const [key, v] of Object.entries(value as Record)) { + if (/_id$|_ids$/.test(key)) continue; + if (looksLikeUuid(v)) continue; + out[key] = stripWorkspaceIds(v); + } + return out; + } + return value; +} + +/** Produce the portable `data` for one node, stripping workspace bindings and + * tagging with a requiresBinding marker where re-binding is needed. */ +function sanitizeNodeData(kind: FlowNodeKind, data: Record): Record { + switch (kind) { + case 'instruction': { + // Pure text — fully portable. + const text = typeof data.text === 'string' ? data.text : ''; + return { text }; + } + case 'decision': { + // Pure branching logic — fully portable. + return { + question: typeof data.question === 'string' ? data.question : '', + branches: canonical(data.branches ?? {}), + default_branch: typeof data.default_branch === 'string' ? data.default_branch : '', + }; + } + case 'doc': { + // Drop doc_id; keep the instruction that describes what the doc is for. + const out: Record = { requiresBinding: 'doc' as BindingKind }; + if (typeof data.instruction === 'string') out.instruction = data.instruction; + return out; + } + case 'docs': { + // Drop concrete doc_ids; keep a type filter (portable) + instruction. + const out: Record = { requiresBinding: 'docs' as BindingKind }; + if (data.filter && typeof data.filter === 'object' && !Array.isArray(data.filter)) { + // filter.type is a portable doc-type string; strip any stray ids defensively. + out.filter = stripWorkspaceIds(data.filter); + } + if (typeof data.instruction === 'string') out.instruction = data.instruction; + return out; + } + case 'capture': { + // Drop target_folder_id (optional local folder); keep authoring intent. + const out: Record = { + requiresBinding: 'capture-folder' as BindingKind, + title_hint: typeof data.title_hint === 'string' ? data.title_hint : '', + instruction: typeof data.instruction === 'string' ? data.instruction : '', + }; + if (typeof data.autonomous === 'boolean') out.autonomous = data.autonomous; + return out; + } + default: { + // Unknown/future kind — best-effort strip so nothing workspace-scoped leaks. + return stripWorkspaceIds(data) as Record; + } + } +} + +/** + * Convert a workspace flow (its published-version graph) into a portable + * template. Pure — no DB access. Output contains no source-workspace UUIDs. + */ +export function sanitizeFlowForPublish( + nodes: FlowNode[], + edges: FlowEdge[], + meta: FlowTemplateMeta, +): PortableFlowTemplate { + const portableNodes: PortableNode[] = nodes.map((n) => ({ + clientNodeId: n.client_node_id, + kind: n.kind, + title: n.title, + positionX: n.position_x, + positionY: n.position_y, + data: canonical(sanitizeNodeData(n.kind, n.data ?? {})), + })); + + const portableEdges: PortableEdge[] = edges.map((e) => ({ + fromNodeId: e.from_node_id, + toNodeId: e.to_node_id, + fromSocket: e.from_socket, + })); + + return { + schemaVersion: SCHEMA_VERSION, + name: meta.name, + description: meta.description ?? null, + tags: (meta.tags ?? []).slice(), + nodes: portableNodes, + edges: portableEdges, + }; +} + +/** Read the requiresBinding marker off a portable node's data, if present. */ +function bindingOf(data: Record): BindingKind | null { + const rb = data.requiresBinding; + return rb === 'doc' || rb === 'docs' || rb === 'capture-folder' ? rb : null; +} + +/** + * Turn a portable template into the rows for a new *local* draft flow. Pure — + * no DB access. Unbound nodes keep their requiresBinding marker and are also + * returned in `unboundNodes` so the import route + UI can prompt re-binding. + * + * Throws on an unsupported schemaVersion — callers should surface a + * "please upgrade" message rather than silently importing a format they don't + * understand. + */ +export function rehydrateFlowFromTemplate(template: PortableFlowTemplate): RehydratedFlow { + if (template.schemaVersion !== SCHEMA_VERSION) { + throw new Error( + `Unsupported flow template schemaVersion ${template.schemaVersion} (this instance supports ${SCHEMA_VERSION}). Please upgrade.`, + ); + } + + const nodes: FlowNode[] = template.nodes.map((n) => ({ + client_node_id: n.clientNodeId, + kind: n.kind, + title: n.title, + position_x: n.positionX, + position_y: n.positionY, + data: { ...n.data }, + })); + + const edges: FlowEdge[] = template.edges.map((e) => ({ + from_node_id: e.fromNodeId, + to_node_id: e.toNodeId, + from_socket: e.fromSocket, + })); + + const unboundNodes: UnboundNode[] = []; + for (const n of template.nodes) { + const binding = bindingOf(n.data); + if (binding) unboundNodes.push({ clientNodeId: n.clientNodeId, kind: n.kind, requiresBinding: binding }); + } + + return { + name: template.name, + description: template.description ?? null, + nodes, + edges, + unboundNodes, + }; +} diff --git a/apps/api/src/lib/flows/template-schema.ts b/apps/api/src/lib/flows/template-schema.ts new file mode 100644 index 000000000..148633a6d --- /dev/null +++ b/apps/api/src/lib/flows/template-schema.ts @@ -0,0 +1,131 @@ +/** + * Portable flow template schema + validator (Community Flows — Phase 0, P0-3). + * + * This is the trust boundary for templates crossing between workspaces and + * instances. It runs at the hub publish-ingest (P1-4) AND at import (P2-3) so + * a malicious or malformed template can never create bad flow rows. + * + * Forward-compat rule: only SCHEMA_VERSION is accepted. An unknown future + * version is rejected with `unsupported_schema_version` so an older instance + * refuses (rather than mangles) a newer format. + */ +import { z } from 'zod'; +import { SCHEMA_VERSION, type PortableFlowTemplate } from './portability.js'; + +/** Hard cap on serialised template size to bound abuse (256 KB). */ +export const MAX_TEMPLATE_BYTES = 256 * 1024; +/** Max tags per template. */ +export const MAX_TAGS = 10; + +const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; + +const portableNodeSchema = z.object({ + clientNodeId: z.string().min(1).max(128), + kind: z.enum(['doc', 'docs', 'instruction', 'decision', 'capture']), + title: z.string().max(500), + positionX: z.number().finite(), + positionY: z.number().finite(), + data: z.record(z.unknown()), +}); + +const portableEdgeSchema = z.object({ + fromNodeId: z.string().min(1).max(128), + toNodeId: z.string().min(1).max(128), + fromSocket: z.string().max(128), +}); + +export const portableTemplateSchema = z.object({ + schemaVersion: z.number().int(), + name: z.string().min(1).max(200), + description: z.string().max(2000).nullable(), + tags: z.array(z.string().min(1).max(40)).max(MAX_TAGS), + nodes: z.array(portableNodeSchema).min(1), + edges: z.array(portableEdgeSchema), +}); + +export interface TemplateValidationError { + code: + | 'schema_shape' + | 'unsupported_schema_version' + | 'oversize' + | 'uuid_in_node_data' + | 'duplicate_client_node_id' + | 'edge_unknown_node'; + message: string; +} + +export type ValidateTemplateResult = + | { ok: true; template: PortableFlowTemplate } + | { ok: false; errors: TemplateValidationError[] }; + +/** Recursively test whether any string anywhere in a value looks like a UUID. */ +function containsUuid(value: unknown): boolean { + if (typeof value === 'string') return UUID_RE.test(value); + if (Array.isArray(value)) return value.some(containsUuid); + if (value && typeof value === 'object') return Object.values(value as Record).some(containsUuid); + return false; +} + +/** + * Validate an untrusted template. Returns the typed template on success, or a + * list of specific errors on failure. Never throws. + */ +export function validateTemplate(input: unknown): ValidateTemplateResult { + const errors: TemplateValidationError[] = []; + + // 1. Structural shape. + const parsed = portableTemplateSchema.safeParse(input); + if (!parsed.success) { + return { + ok: false, + errors: [{ code: 'schema_shape', message: parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ') }], + }; + } + const template = parsed.data as PortableFlowTemplate; + + // 2. Version. + if (template.schemaVersion !== SCHEMA_VERSION) { + errors.push({ + code: 'unsupported_schema_version', + message: `Template schemaVersion ${template.schemaVersion} is not supported (expected ${SCHEMA_VERSION}).`, + }); + } + + // 3. Size cap. + const bytes = Buffer.byteLength(JSON.stringify(template), 'utf8'); + if (bytes > MAX_TEMPLATE_BYTES) { + errors.push({ code: 'oversize', message: `Template is ${bytes} bytes; max is ${MAX_TEMPLATE_BYTES}.` }); + } + + // 4. Defence-in-depth: no workspace UUIDs may survive inside node data. + for (const node of template.nodes) { + if (containsUuid(node.data)) { + errors.push({ + code: 'uuid_in_node_data', + message: `Node '${node.clientNodeId}' data contains a UUID-shaped value — workspace ids must be stripped before publish.`, + }); + } + } + + // 5. Unique client node ids. + const seen = new Set(); + for (const node of template.nodes) { + if (seen.has(node.clientNodeId)) { + errors.push({ code: 'duplicate_client_node_id', message: `Duplicate clientNodeId '${node.clientNodeId}'.` }); + } + seen.add(node.clientNodeId); + } + + // 6. Edges reference existing nodes. + for (const edge of template.edges) { + if (!seen.has(edge.fromNodeId)) { + errors.push({ code: 'edge_unknown_node', message: `Edge from unknown node '${edge.fromNodeId}'.` }); + } + if (!seen.has(edge.toNodeId)) { + errors.push({ code: 'edge_unknown_node', message: `Edge to unknown node '${edge.toNodeId}'.` }); + } + } + + if (errors.length > 0) return { ok: false, errors }; + return { ok: true, template }; +} diff --git a/apps/api/src/lib/flows/validate.ts b/apps/api/src/lib/flows/validate.ts index f6cbeacb9..9ed263d00 100644 --- a/apps/api/src/lib/flows/validate.ts +++ b/apps/api/src/lib/flows/validate.ts @@ -61,7 +61,19 @@ export interface FlowValidationResult { const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -export function validateFlow(nodes: FlowNode[], edges: FlowEdge[]): FlowValidationResult { +/** + * @param opts.mode 'publish' (default, strict) requires every doc/docs binding + * to be resolved. 'draft' tolerates a doc/docs node that was imported from a + * community template and still carries a `requiresBinding` marker with no id — + * an imported flow is a legal DRAFT before the user re-binds, but must be + * fully bound before it can be published. + */ +export function validateFlow( + nodes: FlowNode[], + edges: FlowEdge[], + opts: { mode?: 'draft' | 'publish' } = {}, +): FlowValidationResult { + const mode = opts.mode ?? 'publish'; const errors: FlowValidationError[] = []; if (nodes.length === 0) { @@ -195,18 +207,21 @@ export function validateFlow(nodes: FlowNode[], edges: FlowEdge[]): FlowValidati // 5. Per-kind data shape. for (const node of nodes) { - errors.push(...validateNodeData(node)); + errors.push(...validateNodeData(node, mode)); } return { valid: errors.length === 0, errors }; } -function validateNodeData(node: FlowNode): FlowValidationError[] { +function validateNodeData(node: FlowNode, mode: 'draft' | 'publish'): FlowValidationError[] { const errors: FlowValidationError[] = []; const { kind, data, client_node_id } = node; + // An imported-but-unbound node carries this marker; it is legal in a draft. + const pendingBinding = mode === 'draft' && Boolean(data.requiresBinding); switch (kind) { case 'doc': { + if (pendingBinding) break; if (typeof data.doc_id !== 'string' || !UUID_RE.test(data.doc_id)) { errors.push({ code: 'invalid_node_data', @@ -224,6 +239,7 @@ function validateNodeData(node: FlowNode): FlowValidationError[] { break; } case 'docs': { + if (pendingBinding) break; const hasIds = Array.isArray(data.doc_ids) && data.doc_ids.length > 0 && diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts index 72bec135b..2b109d680 100644 --- a/apps/api/src/plugins/auth.ts +++ b/apps/api/src/plugins/auth.ts @@ -87,6 +87,10 @@ export const authPlugin: FastifyPluginAsync = fp(async (app) => { if (url.startsWith('/api/hooks/') || url === '/install/claude-hooks.sh') return; // Public doc reader — no auth required. if (url.startsWith('/api/docs/public/')) return; + // Community Flows hub (EE) — cross-instance catalog. Browse/import/report are + // anonymous; publish/unpublish authenticate via a community-license key + // inside the handler (not the app JWT). Bypass the session-auth gate for all. + if (url.startsWith('/api/hub/')) return; // OnlyOffice — server-to-server calls from OnlyOffice container, no cookie. // /callback auth: OnlyOffice JWT signature. /file auth: tenantId query param. if (url === '/api/onlyoffice/callback') return; diff --git a/apps/api/src/routes/community.ts b/apps/api/src/routes/community.ts new file mode 100644 index 000000000..01cb98840 --- /dev/null +++ b/apps/api/src/routes/community.ts @@ -0,0 +1,330 @@ +/** + * Community Flows — CORE client routes (Phase 2, P2-2…P2-6). + * + * These ship to EVERY instance (cloud + self-hosted). They proxy the central + * hub (so the browser never calls it directly / cross-origin), import a hub + * template into the caller's workspace as a new draft flow, and publish a local + * flow up to the hub. All hub I/O goes through lib/community/hub-client. + * + * Registered from server.ts as core (NOT ee) — self-hosters must be able to + * browse/import/publish. When COMMUNITY_HUB_ENABLED=false every route returns + * 404 community_disabled. + */ +import type { FastifyPluginAsync, FastifyReply } from 'fastify'; +import { and, eq, isNull } from 'drizzle-orm'; +import { nanoid } from 'nanoid'; +import { db } from '../db/index.js'; +import { flows, flowVersions, flowNodes, flowEdges, workspaceMembers } from '../db/schema.js'; +import { withTenant } from '../db/with-tenant.js'; +import { enforceFreeFlowLimit } from '../plugins/free-limits.js'; +import { enforceRateLimit } from '../lib/auth-rate-limit.js'; +import { sanitizeFlowForPublish, rehydrateFlowFromTemplate } from '../lib/flows/portability.js'; +import { validateTemplate } from '../lib/flows/template-schema.js'; +import { isCommunityEnabled, getHubKey, getHubBaseUrl } from '../lib/community/hub-config.js'; +import { + hubList, + hubGet, + hubPublish, + hubUnpublish, + hubReport, + hubBumpImport, + HubError, + CommunityDisabledError, +} from '../lib/community/hub-client.js'; + +/** Transaction handle type (mirrors the local alias in db/with-tenant.ts). */ +type Tx = Parameters[0]>[0]; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const WRITE_ROLES = new Set(['owner', 'admin', 'editor']); + +function hubErrorToReply(err: unknown, reply: FastifyReply): FastifyReply { + if (err instanceof CommunityDisabledError) return reply.code(404).send({ error: 'community_disabled' }); + if (err instanceof HubError) { + if (err.status === 404) return reply.code(404).send({ error: 'not_found' }); + if (err.status === 401) return reply.code(400).send({ error: 'hub_rejected_key', message: err.message }); + if (err.status === 422) return reply.code(422).send({ error: 'invalid_template', body: err.body }); + return reply.code(502).send({ error: 'hub_error', message: err.message }); + } + reply.log.error({ err }, 'community route unexpected error'); + return reply.code(500).send({ error: 'internal_error' }); +} + +async function resolveFlowTx(tx: Tx, idOrSlug: string) { + const [flow] = await tx + .select() + .from(flows) + .where(and(UUID_RE.test(idOrSlug) ? eq(flows.id, idOrSlug) : eq(flows.slug, idOrSlug), isNull(flows.deletedAt))) + .limit(1); + return flow ?? null; +} + +async function memberRole(tx: Tx, workspaceId: string, userId: string): Promise { + const [m] = await tx + .select({ role: workspaceMembers.role }) + .from(workspaceMembers) + .where(and(eq(workspaceMembers.workspaceId, workspaceId), eq(workspaceMembers.userId, userId))) + .limit(1); + return m?.role ?? null; +} + +export const communityRoutes: FastifyPluginAsync = async (app) => { + // Guard: whole feature disabled → 404 for every route EXCEPT the status + // endpoint, which must always report so Settings can show the disabled state. + app.addHook('preHandler', async (req, reply) => { + if (req.url.startsWith('/api/community/config')) return; + if (!isCommunityEnabled()) return reply.code(404).send({ error: 'community_disabled' }); + }); + + // ── GET /api/community/config — non-secret status for Settings ────────────── + app.get('/api/community/config', async (req, reply) => { + if (!req.auth) return reply.code(401).send({ error: 'unauthorized' }); + return reply.send({ + enabled: isCommunityEnabled(), + hub_url: getHubBaseUrl(), + can_publish: isCommunityEnabled() && !!getHubKey(), + }); + }); + + // ── GET /api/community/flows — browse (proxied) ───────────────────────────── + app.get('/api/community/flows', async (req, reply) => { + if (!req.auth) return reply.code(401).send({ error: 'unauthorized' }); + const q = req.query as Record; + try { + const result = await hubList({ + q: q.q, + tag: q.tag, + sort: q.sort, + cursor: q.cursor ? Number(q.cursor) : undefined, + limit: q.limit ? Number(q.limit) : undefined, + }); + return reply.send(result); + } catch (err) { + return hubErrorToReply(err, reply); + } + }); + + // ── GET /api/community/flows/:slug — detail (proxied) ─────────────────────── + app.get<{ Params: { slug: string } }>('/api/community/flows/:slug', async (req, reply) => { + if (!req.auth) return reply.code(401).send({ error: 'unauthorized' }); + try { + const detail = await hubGet(req.params.slug); + return reply.send(detail); + } catch (err) { + return hubErrorToReply(err, reply); + } + }); + + // ── POST /api/community/flows/:slug/import — rehydrate into a new local flow ─ + app.post<{ Params: { slug: string } }>('/api/community/flows/:slug/import', async (req, reply) => { + if (!req.auth) return reply.code(401).send({ error: 'unauthorized' }); + const auth = req.auth; + if (await enforceFreeFlowLimit(req, reply, auth.tenant_id)) return; + + // Fetch + validate the template before touching the DB. + let template; + try { + const detail = await hubGet(req.params.slug); + const validated = validateTemplate(detail.template_json); + if (!validated.ok) return reply.code(422).send({ error: 'invalid_template', errors: validated.errors }); + template = validated.template; + } catch (err) { + return hubErrorToReply(err, reply); + } + + const rehydrated = rehydrateFlowFromTemplate(template); + + try { + const result = await withTenant(auth.tenant_id, async (tx) => { + const role = await memberRole(tx, auth.tenant_id, auth.sub); + if (!role || !WRITE_ROLES.has(role)) return { error: 'insufficient_role' as const }; + + const baseSlug = + rehydrated.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 44) || 'flow'; + const slug = `${baseSlug}-${nanoid(6).toLowerCase().replace(/[^a-z0-9]/g, 'x')}`; + + const [createdFlow] = await tx + .insert(flows) + .values({ + workspaceId: auth.tenant_id, + slug, + name: rehydrated.name, + description: rehydrated.description, + createdBy: auth.sub, + }) + .returning(); + if (!createdFlow) throw new Error('flow_insert_failed'); + + const [draft] = await tx + .insert(flowVersions) + .values({ + flowId: createdFlow.id, + workspaceId: auth.tenant_id, + versionNumber: 1, + isPublished: false, + createdBy: auth.sub, + }) + .returning(); + if (!draft) throw new Error('draft_insert_failed'); + + if (rehydrated.nodes.length > 0) { + await tx.insert(flowNodes).values( + rehydrated.nodes.map((n) => ({ + flowVersionId: draft.id, + clientNodeId: n.client_node_id, + kind: n.kind, + title: n.title, + positionX: n.position_x, + positionY: n.position_y, + data: n.data, + })), + ); + } + if (rehydrated.edges.length > 0) { + await tx.insert(flowEdges).values( + rehydrated.edges.map((e) => ({ + flowVersionId: draft.id, + fromNodeId: e.from_node_id, + toNodeId: e.to_node_id, + fromSocket: e.from_socket, + })), + ); + } + + return { flow_slug: createdFlow.slug, unbound_nodes: rehydrated.unboundNodes }; + }); + + if ('error' in result) return reply.code(403).send(result); + + // Best-effort import counter bump — never fail the import on this. + void hubBumpImport(req.params.slug).catch(() => undefined); + + return reply.code(201).send(result); + } catch (err) { + return hubErrorToReply(err, reply); + } + }); + + // ── POST /api/community/flows/:slug/report — report intake (proxied) ──────── + app.post<{ Params: { slug: string } }>('/api/community/flows/:slug/report', async (req, reply) => { + if (!req.auth) return reply.code(401).send({ error: 'unauthorized' }); + if (await enforceRateLimit(req, reply, { category: 'community-report', max: 10, windowSec: 3600 })) return; + const body = (req.body ?? {}) as { reason?: string; detail?: string }; + try { + await hubReport(req.params.slug, { reason: body.reason ?? 'other', detail: body.detail }); + return reply.code(202).send({ received: true }); + } catch (err) { + return hubErrorToReply(err, reply); + } + }); + + // ── POST /api/flows/:id/publish-to-community — sanitize + upload ──────────── + app.post<{ Params: { id: string }; Body: { tags?: string[]; description_override?: string } }>( + '/api/flows/:id/publish-to-community', + async (req, reply) => { + if (!req.auth) return reply.code(401).send({ error: 'unauthorized' }); + const auth = req.auth; + if (!getHubKey()) { + return reply.code(400).send({ error: 'no_community_key', message: 'Add your community key to publish.' }); + } + if (await enforceRateLimit(req, reply, { category: 'community-publish', identifier: auth.tenant_id, max: 20, windowSec: 3600 })) { + return; + } + + // Load + sanitize the published version inside the tenant scope. + const prepared = await withTenant(auth.tenant_id, async (tx) => { + const role = await memberRole(tx, auth.tenant_id, auth.sub); + if (!role || !WRITE_ROLES.has(role)) return { error: 'insufficient_role' as const }; + + const flow = await resolveFlowTx(tx, req.params.id); + if (!flow) return { error: 'flow_not_found' as const }; + if (!flow.publishedVersionId) return { error: 'not_published' as const }; + + const nodes = await tx + .select({ + client_node_id: flowNodes.clientNodeId, + kind: flowNodes.kind, + title: flowNodes.title, + position_x: flowNodes.positionX, + position_y: flowNodes.positionY, + data: flowNodes.data, + }) + .from(flowNodes) + .where(eq(flowNodes.flowVersionId, flow.publishedVersionId)); + const edges = await tx + .select({ + from_node_id: flowEdges.fromNodeId, + to_node_id: flowEdges.toNodeId, + from_socket: flowEdges.fromSocket, + }) + .from(flowEdges) + .where(eq(flowEdges.flowVersionId, flow.publishedVersionId)); + + return { + flowId: flow.id, + name: flow.name, + description: (req.body?.description_override ?? flow.description) ?? null, + nodes, + edges, + }; + }); + + if ('error' in prepared) { + const status = prepared.error === 'flow_not_found' ? 404 : prepared.error === 'not_published' ? 409 : 403; + return reply.code(status).send(prepared); + } + + const tags = Array.isArray(req.body?.tags) ? req.body.tags.filter((t) => typeof t === 'string').slice(0, 10) : []; + const template = sanitizeFlowForPublish(prepared.nodes as never, prepared.edges as never, { + name: prepared.name, + description: prepared.description, + tags, + }); + + try { + const published = await hubPublish({ + template_json: template, + name: prepared.name, + description: prepared.description, + tags, + }); + await withTenant(auth.tenant_id, async (tx) => { + await tx.update(flows).set({ communitySlug: published.slug }).where(eq(flows.id, prepared.flowId)); + }); + return reply.send({ community_slug: published.slug, community_url: published.url }); + } catch (err) { + return hubErrorToReply(err, reply); + } + }, + ); + + // ── DELETE /api/flows/:id/publish-to-community — owner unpublish ───────────── + app.delete<{ Params: { id: string } }>('/api/flows/:id/publish-to-community', async (req, reply) => { + if (!req.auth) return reply.code(401).send({ error: 'unauthorized' }); + const auth = req.auth; + + const found = await withTenant(auth.tenant_id, async (tx) => { + const role = await memberRole(tx, auth.tenant_id, auth.sub); + if (!role || !WRITE_ROLES.has(role)) return { error: 'insufficient_role' as const }; + const flow = await resolveFlowTx(tx, req.params.id); + if (!flow) return { error: 'flow_not_found' as const }; + return { flowId: flow.id, communitySlug: flow.communitySlug }; + }); + + if ('error' in found) { + return reply.code(found.error === 'flow_not_found' ? 404 : 403).send(found); + } + if (!found.communitySlug) return reply.send({ removed: true }); + + try { + await hubUnpublish(found.communitySlug); + } catch (err) { + // If the hub rejects, still clear locally only on 404 (already gone). + if (!(err instanceof HubError && err.status === 404)) return hubErrorToReply(err, reply); + } + await withTenant(auth.tenant_id, async (tx) => { + await tx.update(flows).set({ communitySlug: null }).where(eq(flows.id, found.flowId)); + }); + return reply.send({ removed: true }); + }); +}; diff --git a/apps/api/src/routes/flows.ts b/apps/api/src/routes/flows.ts index a6bed2042..eaff2990f 100644 --- a/apps/api/src/routes/flows.ts +++ b/apps/api/src/routes/flows.ts @@ -412,9 +412,12 @@ export const flowsRoutes: FastifyPluginAsync = async (app) => { // Validate graph invariants up-front. We don't run inside the tx // because validation is pure and shouldn't hold a row lock. + // Draft mode tolerates imported-but-unbound community nodes (requiresBinding); + // publish re-validates in strict mode, so bindings must be resolved to ship. const valid = validateFlow( parsed.data.nodes as ValidFlowNode[], parsed.data.edges as ValidFlowEdge[], + { mode: 'draft' }, ); if (!valid.valid) { return reply.code(400).send({ error: 'invalid_flow', errors: valid.errors }); diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index f27dafce1..e881cc241 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -25,6 +25,7 @@ import { searchRoutes } from './routes/search.js'; import { foldersRoutes } from './routes/folders.js'; import { mcpTokenRoutes } from './routes/mcp-tokens.js'; import { flowsRoutes } from './routes/flows.js'; +import { communityRoutes } from './routes/community.js'; import { healthRoutes } from './routes/health.js'; import { invitationsRoutes } from './routes/invitations.js'; import { membersRoutes } from './routes/members.js'; @@ -142,6 +143,7 @@ await app.register(decisionApprovalsRoutes); await app.register(searchRoutes); await app.register(foldersRoutes); await app.register(flowsRoutes); +await app.register(communityRoutes); await app.register(invitationsRoutes); await app.register(membersRoutes); await app.register(calendarRoutes); diff --git a/apps/web/src/components/app/Sidebar.tsx b/apps/web/src/components/app/Sidebar.tsx index 289414aac..ae2cc7ce6 100644 --- a/apps/web/src/components/app/Sidebar.tsx +++ b/apps/web/src/components/app/Sidebar.tsx @@ -26,6 +26,7 @@ export function Sidebar({ currentPath, workspaceMode, typeCounts = {}, isAdmin = const isOnContent = currentPath === '/app/content' || currentPath.startsWith('/app/content'); const isOnFlows = currentPath.startsWith('/app/flows'); + const isOnCommunity = currentPath.startsWith('/app/community'); const isOnClaude = currentPath === '/app/connections/claude'; const isOnDrive = currentPath === '/app/connections/drive'; const isOnChatGPT = currentPath === '/app/connections/chatgpt'; @@ -154,6 +155,20 @@ export function Sidebar({ currentPath, workspaceMode, typeCounts = {}, isAdmin = All flows + + + + + + + + + Community + + {/* ── MEETINGS ─────────────────────────────── */} diff --git a/apps/web/src/components/community/CommunityBrowse.tsx b/apps/web/src/components/community/CommunityBrowse.tsx new file mode 100644 index 000000000..944eec34c --- /dev/null +++ b/apps/web/src/components/community/CommunityBrowse.tsx @@ -0,0 +1,183 @@ +/** + * Community flows browse grid (Community Flows — Phase 3, P3-1). + * Fetches the hub catalog via the instance proxy (/api/community/flows), with + * search, tag filter, and popular/new sort. Disabled instances get a notice. + */ +import { type JSX, useEffect, useState, useCallback } from 'react'; + +const ink = 'var(--ink, #e7e7e9)'; +const soft = 'var(--ink-soft, #a1a1aa)'; +const muted = 'var(--ink-muted, #71717a)'; +const line = 'var(--line, rgba(255,255,255,0.1))'; +const surface = 'var(--surface, rgba(255,255,255,0.02))'; + +interface HubListItem { + slug: string; + name: string; + description: string | null; + tags: string[]; + nodeCount: number; + importCount: number; + publisherHandle: string | null; + createdAt: string; +} + +export function CommunityBrowse(): JSX.Element { + const [items, setItems] = useState([]); + const [q, setQ] = useState(''); + const [sort, setSort] = useState<'popular' | 'new'>('popular'); + const [tag, setTag] = useState(null); + const [loading, setLoading] = useState(true); + const [disabled, setDisabled] = useState(false); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const qs = new URLSearchParams(); + if (q.trim()) qs.set('q', q.trim()); + if (tag) qs.set('tag', tag); + qs.set('sort', sort); + const res = await fetch(`/api/community/flows?${qs.toString()}`, { credentials: 'include' }); + if (res.status === 404) { + setDisabled(true); + return; + } + if (!res.ok) throw new Error(String(res.status)); + const body = (await res.json()) as { flows: HubListItem[] }; + setItems(body.flows); + } catch { + setError('Could not load community flows.'); + } finally { + setLoading(false); + } + }, [q, tag, sort]); + + useEffect(() => { + const t = setTimeout(() => void load(), 200); + return () => clearTimeout(t); + }, [load]); + + if (disabled) { + return ( +
+ The community hub is disabled on this instance. +
+ ); + } + + const allTags = Array.from(new Set(items.flatMap((i) => i.tags))).slice(0, 16); + + return ( +
+
+ Community +
+

+ Flows from the community +

+

+ Browse flows others have published, and use them in your workspace in one click. Doc and folder + references are stripped on publish — you re-bind them to your own after importing. +

+ +
+ setQ(e.target.value)} + placeholder="Search flows…" + style={{ + flex: 1, minWidth: 200, padding: '9px 12px', borderRadius: 8, background: surface, + border: `1px solid ${line}`, color: ink, fontSize: 14, outline: 'none', + }} + /> +
+ {(['popular', 'new'] as const).map((s) => ( + + ))} +
+
+ + {allTags.length > 0 && ( +
+ setTag(null)}>All + {allTags.map((t) => ( + setTag(t)}>{t} + ))} +
+ )} + + {loading ? ( +
Loading…
+ ) : error ? ( +
{error}
+ ) : items.length === 0 ? ( +
+

No community flows yet

+

Be the first — publish one of your flows from its editor.

+
+ ) : ( + + )} +
+ ); +} + +function Chip({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }): JSX.Element { + return ( + + ); +} diff --git a/apps/web/src/components/community/CommunityFlowDetail.tsx b/apps/web/src/components/community/CommunityFlowDetail.tsx new file mode 100644 index 000000000..885a36987 --- /dev/null +++ b/apps/web/src/components/community/CommunityFlowDetail.tsx @@ -0,0 +1,182 @@ +/** + * Community flow detail + import (Community Flows — Phase 3, P3-2). + * Read-only preview of a hub template, "Use this flow" import, and report. + */ +import { type JSX, useEffect, useState } from 'react'; + +const ink = 'var(--ink, #e7e7e9)'; +const soft = 'var(--ink-soft, #a1a1aa)'; +const muted = 'var(--ink-muted, #71717a)'; +const line = 'var(--line, rgba(255,255,255,0.1))'; +const surface = 'var(--surface, rgba(255,255,255,0.02))'; + +const KIND_COLOR: Record = { + doc: '#6ea8fe', docs: '#6ea8fe', instruction: '#f0997b', decision: '#34d399', capture: '#c084fc', +}; + +interface PNode { + clientNodeId: string; + kind: string; + title: string; + positionX: number; + positionY: number; + data: Record; +} +interface PEdge { fromNodeId: string; toNodeId: string; fromSocket: string } +interface Detail { + slug: string; + name: string; + description: string | null; + tags: string[]; + schema_version: number; + template_json: { nodes: PNode[]; edges: PEdge[] }; + node_count: number; + import_count: number; + publisher_handle: string | null; +} + +export function CommunityFlowDetail({ slug }: { slug: string }): JSX.Element { + const [detail, setDetail] = useState(null); + const [err, setErr] = useState(null); + const [importing, setImporting] = useState(false); + const [reporting, setReporting] = useState(false); + + useEffect(() => { + void (async () => { + try { + const res = await fetch(`/api/community/flows/${encodeURIComponent(slug)}`, { credentials: 'include' }); + if (res.status === 404) { setErr('This community flow was not found or has been removed.'); return; } + if (!res.ok) throw new Error(String(res.status)); + setDetail((await res.json()) as Detail); + } catch { + setErr('Could not load this community flow.'); + } + })(); + }, [slug]); + + async function doImport(): Promise { + setImporting(true); + try { + const res = await fetch(`/api/community/flows/${encodeURIComponent(slug)}/import`, { + method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: '{}', + }); + if (res.status === 403) { alert('Only workspace editors can import flows.'); return; } + if (!res.ok) { alert('Import failed.'); return; } + const body = (await res.json()) as { flow_slug: string }; + window.location.href = `/app/flows/${body.flow_slug}?imported=1`; + } finally { + setImporting(false); + } + } + + async function doReport(reason: string): Promise { + setReporting(true); + try { + await fetch(`/api/community/flows/${encodeURIComponent(slug)}/report`, { + method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reason }), + }); + alert('Thanks — this flow has been reported.'); + } finally { + setReporting(false); + } + } + + if (err) return
{err}
; + if (!detail) return
Loading…
; + + const nodes = detail.template_json.nodes ?? []; + const edges = detail.template_json.edges ?? []; + const ordered = [...nodes].sort((a, b) => a.positionY - b.positionY || a.positionX - b.positionX); + const titleOf = new Map(nodes.map((n) => [n.clientNodeId, n.title])); + const nextOf = new Map(); + for (const e of edges) { + const arr = nextOf.get(e.fromNodeId) ?? []; + arr.push(e.toNodeId); + nextOf.set(e.fromNodeId, arr); + } + const bindCount = nodes.filter((n) => n.data && n.data.requiresBinding).length; + + return ( +
+
+ Community flow · read-only +
+
+
+

{detail.name}

+ {detail.description &&

{detail.description}

} +
+ {detail.node_count} steps + ↓ {detail.import_count} imports + {detail.publisher_handle && by @{detail.publisher_handle}} +
+
+ +
+ + {detail.tags.length > 0 && ( +
+ {detail.tags.map((t) => ( + {t} + ))} +
+ )} + + {bindCount > 0 && ( +
+ {bindCount} step{bindCount === 1 ? '' : 's'} reference docs or folders. After importing, you'll pick your own + workspace docs to bind them — no content from the original workspace is included. +
+ )} + +
+ {ordered.map((n, i) => { + const instr = typeof n.data?.instruction === 'string' ? n.data.instruction + : typeof n.data?.text === 'string' ? (n.data.text as string) : null; + const nexts = (nextOf.get(n.clientNodeId) ?? []).map((id) => titleOf.get(id) ?? id); + const needsBind = Boolean(n.data?.requiresBinding); + return ( +
+
+ {i + 1} + + + {n.kind} + + {n.title} + {needsBind && ( + + needs binding + + )} +
+ {instr &&
{instr}
} + {nexts.length > 0 &&
→ {nexts.join(' · ')}
} +
+ ); + })} +
+ +
+ +
+
+ ); +} diff --git a/apps/web/src/components/flows/FlowHeader.tsx b/apps/web/src/components/flows/FlowHeader.tsx index cecb6c11a..24e87a7d2 100644 --- a/apps/web/src/components/flows/FlowHeader.tsx +++ b/apps/web/src/components/flows/FlowHeader.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { Play, ArrowLeft, Clock, CheckCircle, AlertCircle, Loader2, Save, Share2, Copy, Check } from 'lucide-react'; +import { Play, ArrowLeft, Clock, CheckCircle, AlertCircle, Loader2, Save, Share2, Copy, Check, Globe } from 'lucide-react'; import { Button } from '../ui/Button'; import { StatusPill } from '../ui/StatusPill'; import { MonoLabel } from '../ui/typography'; @@ -36,6 +36,7 @@ export function FlowHeader({ onPublishClick, }: Props) { const [shareOpen, setShareOpen] = useState(false); + const [communityOpen, setCommunityOpen] = useState(false); return (
@@ -129,6 +130,16 @@ export function FlowHeader({ Share + + +
+
+ + +
+ + ) : ( + <> + + setTagInput(e.target.value)} + placeholder="research, onboarding, qa" + className="mt-1.5 w-full h-9 px-3 rounded-md text-[12.5px] outline-none" + style={{ background: 'var(--surface-input, rgba(255,255,255,0.04))', color: 'var(--ink)', border: '0.5px solid var(--line)' }} + /> + {err &&

{err}

} +
+ + +
+ + )} +
+ + ); +} diff --git a/apps/web/src/components/settings/CommunitySettings.tsx b/apps/web/src/components/settings/CommunitySettings.tsx new file mode 100644 index 000000000..8f9904f33 --- /dev/null +++ b/apps/web/src/components/settings/CommunitySettings.tsx @@ -0,0 +1,73 @@ +/** + * Community settings (Community Flows — Phase 3, P3-6). + * Shows this instance's community-hub status. The publish credential + * (COMMUNITY_HUB_KEY) is an instance env var, so this surface is read-only: + * it reports the hub URL, whether the feature is enabled, and whether a key is + * configured (i.e. whether publishing is available). Self-hosters set the key + * via env; there is no browser-writable secret here by design. + */ +import { type JSX, useEffect, useState } from 'react'; + +const ink = 'var(--ink, #e7e7e9)'; +const soft = 'var(--ink-soft, #a1a1aa)'; +const muted = 'var(--ink-muted, #71717a)'; +const line = 'var(--line, rgba(255,255,255,0.1))'; +const surface = 'var(--surface, rgba(255,255,255,0.02))'; + +interface Config { + enabled: boolean; + hub_url: string; + can_publish: boolean; +} + +function Row({ label, children }: { label: string; children: React.ReactNode }): JSX.Element { + return ( +
+ {label} + {children} +
+ ); +} + +export function CommunitySettings(): JSX.Element { + const [cfg, setCfg] = useState(null); + const [err, setErr] = useState(null); + + useEffect(() => { + void (async () => { + try { + const res = await fetch('/api/community/config', { credentials: 'include' }); + if (!res.ok) throw new Error(String(res.status)); + setCfg((await res.json()) as Config); + } catch { + setErr('Could not load community settings.'); + } + })(); + }, []); + + if (err) return
{err}
; + if (!cfg) return
Loading…
; + + return ( +
+
+ {cfg.enabled ? 'Enabled' : 'Disabled'} + {cfg.hub_url} + + {cfg.can_publish ? ( + Available ✓ + ) : ( + Key not configured + )} + +
+ +

+ Browsing and importing community flows works out of the box. To publish your own flows, + this instance needs a community-license key set as the COMMUNITY_HUB_KEY environment + variable. Get a free key from the community sign-up. To point at a different hub or disable the feature + entirely, set COMMUNITY_HUB_URL / COMMUNITY_HUB_ENABLED. +

+
+ ); +} diff --git a/apps/web/src/layouts/SettingsLayout.astro b/apps/web/src/layouts/SettingsLayout.astro index bb42d323d..195a26736 100644 --- a/apps/web/src/layouts/SettingsLayout.astro +++ b/apps/web/src/layouts/SettingsLayout.astro @@ -44,6 +44,7 @@ const NAV_SECTIONS = [ items: [ { href: '/app/settings/workspace', label: 'Workspace' }, { href: '/app/settings/api-keys', label: 'API Keys' }, + { href: '/app/settings/community', label: 'Community' }, { href: '/app/settings/dev', label: 'Dev / AgentLens' }, ], }, diff --git a/apps/web/src/pages/app/community/[slug].astro b/apps/web/src/pages/app/community/[slug].astro new file mode 100644 index 000000000..7259957fe --- /dev/null +++ b/apps/web/src/pages/app/community/[slug].astro @@ -0,0 +1,25 @@ +--- +import AppLayout from '../../../layouts/AppLayout.astro'; +import { CommunityFlowDetail } from '../../../components/community/CommunityFlowDetail'; + +const auth = Astro.locals.auth; +if (!auth) { + return Astro.redirect('/auth/login'); +} +const { slug } = Astro.params; + +const domainPart = auth.email.split('@')[1]?.split('.')[0] ?? ''; +const workspaceName = domainPart + ? domainPart.charAt(0).toUpperCase() + domainPart.slice(1) + : 'Workspace'; +--- + + + + ← Community + + +
+ +
+
diff --git a/apps/web/src/pages/app/community/index.astro b/apps/web/src/pages/app/community/index.astro new file mode 100644 index 000000000..1676f97d7 --- /dev/null +++ b/apps/web/src/pages/app/community/index.astro @@ -0,0 +1,24 @@ +--- +import AppLayout from '../../../layouts/AppLayout.astro'; +import { CommunityBrowse } from '../../../components/community/CommunityBrowse'; + +const auth = Astro.locals.auth; +if (!auth) { + return Astro.redirect('/auth/login'); +} + +const domainPart = auth.email.split('@')[1]?.split('.')[0] ?? ''; +const workspaceName = domainPart + ? domainPart.charAt(0).toUpperCase() + domainPart.slice(1) + : 'Workspace'; +--- + + + + Community flows + + +
+ +
+
diff --git a/apps/web/src/pages/app/settings/community.astro b/apps/web/src/pages/app/settings/community.astro new file mode 100644 index 000000000..09d6d29a2 --- /dev/null +++ b/apps/web/src/pages/app/settings/community.astro @@ -0,0 +1,17 @@ +--- +import SettingsLayout from '../../../layouts/SettingsLayout.astro'; +import { CommunitySettings } from '../../../components/settings/CommunitySettings'; + +const auth = Astro.locals.auth; +if (!auth) { + return Astro.redirect('/auth/login'); +} +--- + + +

Settings

+

Community

+

Browse and import community flows, and publish your own for others to use.

+ + +
diff --git a/docs/community-flows-e2e-runbook.md b/docs/community-flows-e2e-runbook.md new file mode 100644 index 000000000..505ad6ad1 --- /dev/null +++ b/docs/community-flows-e2e-runbook.md @@ -0,0 +1,33 @@ +# Community Flows — manual E2E runbook (P4-4) + +The automated boundary test (`apps/api/src/lib/flows/community-e2e.test.ts`) proves the +sanitize → validate → rehydrate → bind loop and the no-leak guarantee. This runbook is the live +two-party check that also exercises the HTTP hub + moderation. + +Prereqs: hub deployed (migration `0071` applied); at least one workspace with a valid +`COMMUNITY_HUB_KEY` set (the "publisher"); a second workspace/instance to import from (the "consumer"). + +1. **Publisher builds a flow** with an `instruction`, a `decision`, a `doc` (bound to a private doc), + and a `capture` node. Publish a version. +2. **Publish to community** (header → Community → tags → Publish). Note the returned URL. +3. **Leak check** — on the hub DB: + ```sql + SELECT template_json FROM community_flows WHERE slug = ''; + ``` + Assert the JSON contains **no** UUIDs and **no** private doc content — only titles + instructions. + (`grep -Eo '[0-9a-f-]{36}'` over the value should return nothing.) +4. **Consumer browses** `/app/community`, finds the flow (search/tag), opens the detail page. The + `doc`/`capture` steps show a **needs binding** badge; instruction/decision render fully. +5. **Consumer imports** ("Use this flow") → lands in the editor on a new draft. Confirm: + - instruction/decision steps are identical; + - doc/capture steps exist but are unbound; + - the flow **opens and walks** without error while unbound (graceful fallback). +6. **Consumer binds** the doc node to one of *their* docs, saves. Confirm publish is now allowed + (unbound → strict validation would have blocked it). +7. **Import counter** — reload the listing; `import_count` incremented. +8. **Report + takedown** — consumer clicks **Report**. Staff open admin center → Community, see the + report, **unlist** the flow. Confirm it disappears from `/app/community` browse and its detail + page 404s. Resolve the report. +9. **Unpublish** — publisher opens the flow → Community → **Unpublish**. Confirm the listing is gone. + +Pass = every assertion above holds, especially step 3 (the privacy gate). diff --git a/docs/community-flows.md b/docs/community-flows.md new file mode 100644 index 000000000..66c58784e --- /dev/null +++ b/docs/community-flows.md @@ -0,0 +1,78 @@ +# Community Flows + +Publish your flows to a shared, central catalog that **every Mnema instance** — cloud and +self-hosted — can browse and import from. It's the n8n-community-templates model: one hub, many +instances. + +- **Browse & import**: anonymous / any signed-in user. No key needed. +- **Publish**: needs your free community-license key. +- **Moderation**: flows list immediately (auto-list); anyone can report; staff can take down. + +--- + +## For users + +### Browse & use a flow + +1. Sidebar → **Community** (`/app/community`). +2. Search / filter by tag / sort by Popular or New. +3. Open a flow → **Use this flow**. It's copied into your workspace as a new **draft**. + +Imported flows land in your editor. Any step that referenced a doc or folder in the original +workspace comes in **unbound** (marked "needs binding") — you point it at your own doc/folder before +publishing or fully running it. **No content from the original workspace is ever included** — only the +flow's structure and instructions travel. + +### Publish your flow + +1. Open a flow → publish a **version** (only the published version is uploaded). +2. Header → **Community** → add tags → **Publish to community**. +3. You get a public URL. Re-publishing updates the same listing. **Unpublish** anytime. + +Before it uploads, your flow is **sanitized**: `doc`/`docs`/`capture` steps keep their title and +instruction but drop the concrete `doc_id` / `doc_ids` / `target_folder_id`. That's the privacy +guarantee — importers re-bind their own. + +### Report a flow + +On any listing → **Report this flow**. Staff review reports and can unlist or remove. + +--- + +## For self-hosters + +Everything ships in the core build; the hub itself is hosted by Mnema. Three env vars control it: + +| Var | Default | Purpose | +|-----|---------|---------| +| `COMMUNITY_HUB_URL` | `https://api.theboringpeople.in` | Which hub to browse/publish against. Point elsewhere to use a different hub. | +| `COMMUNITY_HUB_ENABLED` | `true` | Set `false` to **fully disable** the feature (air-gapped installs) — the Community nav, pages, and API all go dark. | +| `COMMUNITY_HUB_KEY` | _(unset)_ | Your community-license key. **Required only to publish.** Browse/import work without it. | + +Get a free community key from the community sign-up. Set `COMMUNITY_HUB_KEY` in `infra/.env`, then +restart the api. Settings → **Community** shows live status (enabled / hub URL / publishing +available). + +The hub verifies your key offline (Ed25519, `verifyLicenseKey`) and stamps your email on your +listings for attribution and takedown — it is never shown publicly beyond the handle (local part). + +--- + +## Moderation policy + +Listings appear immediately. Reports go to a staff queue (admin center → Community). Staff can +**unlist** (hidden, recoverable), **remove** (gone), or **resolve** a report. Publishers can unpublish +their own listings at any time. + +--- + +## How it fits the open-core split + +- **Core** (ships to every self-hoster): the browse/import/publish **client** + the sanitize/rehydrate + portability library. Lives in `apps/api/src/routes/community.ts`, `apps/api/src/lib/community/`, + `apps/api/src/lib/flows/portability.ts`, and the web pages under `apps/web/src/…/community`. +- **EE / hosted** (SaaS only, stripped from the public build): the **hub** itself — the catalog API and + moderation. Lives in `apps/api/src/routes/community-flows-hub.ts` and + `apps/api/src/routes/admin/community.ts`, registered from `apps/api/src/ee/index.ts`. + +A self-hosted instance never runs a hub; it points at one. diff --git a/infra/.env.prod.example b/infra/.env.prod.example index bea18c2ad..674459aa1 100644 --- a/infra/.env.prod.example +++ b/infra/.env.prod.example @@ -71,6 +71,14 @@ RATE_LIMIT_TENANT_DAILY_UNITS=50000 MCP_PROTOCOL_VERSION=2025-11-25 MCP_ORIGIN_ALLOWLIST=https://mnema.example.com +# ── Community Flows hub ─────────────────────────────────────────────────────── +# Central catalog of shareable flow templates, used by cloud + self-hosted alike. +# Browse/import need no key; publishing needs your free community-license key. +# Set COMMUNITY_HUB_ENABLED=false to fully disable (air-gapped installs). +COMMUNITY_HUB_URL=https://api.theboringpeople.in +COMMUNITY_HUB_ENABLED=true +COMMUNITY_HUB_KEY= # community-license key (from community sign-up); required only to publish + # ── Cloudflare R2 (DOCX/PDF attachment storage) ─────────────────────────────── R2_ACCOUNT_ID=your_cloudflare_account_id R2_ACCESS_KEY_ID=your_r2_access_key_id