Skip to content

Commit 43bc2b3

Browse files
authored
Merge pull request #12 from lladawn/codex/production-privacy-encryption-gate
Gate pattern graph and encrypt user content
2 parents b6a5980 + 3a83395 commit 43bc2b3

89 files changed

Lines changed: 4697 additions & 2538 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
NEXT_PUBLIC_APP_URL=http://127.0.0.1:3000
2+
NEXT_ALLOWED_DEV_ORIGINS=
23
NEXT_PUBLIC_ENABLE_ANALYTICS=false
34
NEXT_PUBLIC_UMAMI_SRC=https://breathe-umami.vercel.app/script.js
45
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
@@ -11,6 +12,7 @@ SUPABASE_SERVICE_ROLE_KEY=
1112

1213
# Server-only salts/secrets. Generate long random values before deploy.
1314
MUMBL_TOKEN_HASH_SECRET=
15+
MUMBL_CONTENT_ENCRYPTION_KEY=
1416
MUMBL_SIDE_QUEST_ENCRYPTION_KEY=
1517
CRON_SECRET=
1618

@@ -19,8 +21,18 @@ OPENAI_API_KEY=
1921
OPENAI_MODEL_FIELD_NOTE=gpt-5.4-nano
2022
OPENAI_MAX_DAILY_DRAFTS=20
2123

22-
# Server-only memory graph settings for private dump map sync/search.
23-
SUPERMEMORY_API_KEY=
24+
# Server-only pattern graph settings for private logged-in dump processing.
25+
NEXT_PUBLIC_ENABLE_PATTERN_GRAPH=false
26+
MUMBL_ENABLE_PATTERN_GRAPH=false
27+
OPENAI_SIGNAL_MODEL=gpt-5.4-nano
28+
ANTHROPIC_API_KEY=
29+
ANTHROPIC_INSIGHT_MODEL=claude-haiku-4-5-20251001
30+
MUMBL_PATTERN_GRAPH_FIRST_INSIGHT_AT=10
31+
MUMBL_PATTERN_GRAPH_INSIGHT_INTERVAL=25
32+
MUMBL_ENABLE_PATTERN_TEST_TOOLS=false
33+
34+
# Deprecated Supermemory setting. Kept temporarily while old columns stay inert.
35+
# SUPERMEMORY_API_KEY=
2436

2537
# Server-only Slack beta app settings.
2638
SLACK_CLIENT_ID=

README.md

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ http://127.0.0.1:3000/
3535
- Optional creator-managed room note after creation
3636
- Reads-first room view for published field notes, with legacy feed/wins routes kept for compatibility
3737
- Slack/private-dump to field-note to team-read loop
38+
- Private pattern graph for logged-in dumps, with pgvector-backed working map and private insight review
3839
- Phrase-based reactions with local or logged-in dedupe
3940
- Edit/delete for new room posts through per-post edit tokens, with optional logged-in continuity across browsers
4041
- Private dump and field-note edit/delete, including bulk cleanup for selected private dumps
@@ -68,13 +69,20 @@ Weekly heartbeat generation is scheduled through Vercel Cron in `vercel.json` an
6869

6970
The current generator is deterministic/local and reads only published team-read posts (`posts.type = 'field_note'`). Private dumps, Slack history, hidden feed posts, and presence never feed the heartbeat. An AI provider can replace the generator later while keeping the anonymised published-read payload shape. Heartbeat history and vibe-over-time are displayed from stored heartbeat rows.
7071

72+
## Pattern Graph
73+
74+
Logged-in private dumps are processed asynchronously for private pattern features. OpenAI extracts signals and embeddings into Supabase pgvector-backed `dump_signals`; Anthropic generates milestone insights into `patterns`. Anonymous/session-only dumps are excluded. Pattern APIs are owner-scoped and must not expose source dump content.
75+
76+
Users can review insights at `/patterns` and explore the working map at `/dump/map`. Local and staging can enable `MUMBL_ENABLE_PATTERN_TEST_TOOLS=true` for manual QA controls; production should keep it disabled. See [docs/pattern-graph.md](/Users/dawn/Code/mumbl-app/docs/pattern-graph.md).
77+
7178
## Backend Setup
7279

7380
The frontend now uses these backend route handlers for spaces, posts, reactions, and room reads.
7481

7582
1. Create a Supabase project.
7683
2. Copy `.env.example` to `.env.local`.
77-
3. Fill in `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `MUMBL_TOKEN_HASH_SECRET`, `MUMBL_SIDE_QUEST_ENCRYPTION_KEY`, and `CRON_SECRET`.
84+
3. Fill in `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `MUMBL_TOKEN_HASH_SECRET`, `MUMBL_CONTENT_ENCRYPTION_KEY`, `MUMBL_SIDE_QUEST_ENCRYPTION_KEY`, and `CRON_SECRET`.
85+
Pattern graph work also needs `MUMBL_ENABLE_PATTERN_GRAPH=true`, `OPENAI_API_KEY`, `OPENAI_SIGNAL_MODEL`, `ANTHROPIC_API_KEY`, and `ANTHROPIC_INSIGHT_MODEL`.
7886
4. Authenticate the Supabase CLI with `npx supabase login`.
7987
5. Run `npm run db:link -- your-project-ref` or `npm run db:link -- https://your-project.supabase.co`.
8088
`npm run db:link:staging` reads `.env.local`; `npm run db:link:prod` reads `.env.production.local`.
@@ -87,6 +95,8 @@ Creator access starts with the local room creator token. When a logged-in creato
8795

8896
Creators can delete test or unused rooms from the room danger zone. Deleting a room hard-deletes the room reads/feed signal, frees the slug, and leaves user-owned field notes in the author's dump with their room/post linkage cleared.
8997

98+
User-entered and user-derived text is encrypted into per-row `encrypted_payload` JSON before it is stored. The rollout backfills existing rows, uses `0030_scrub_plaintext_content.sql` as a verification scrub, then `0031_drop_legacy_plaintext_content_columns.sql` removes the legacy plaintext columns. This is server-side field encryption, not end-to-end encryption: Mumbl route handlers can decrypt content when needed to render rooms, draft field notes, generate heartbeats, and serve owner-scoped private data.
99+
90100
Until those variables exist, API routes return a setup `503`.
91101

92102
## Slack Beta Setup
@@ -101,13 +111,13 @@ In Slack app settings:
101111
- Slash command request URL: `https://mumbl.wtf/api/slack/commands`
102112
- Interactivity request URL: `https://mumbl.wtf/api/slack/interactions`
103113
- Event subscriptions request URL: `https://mumbl.wtf/api/slack/events`
104-
- Core bot scopes: `commands`, `users:read`, `users:read.email`
105-
- Optional team-read bot scopes: `chat:write`, `groups:write`, `groups:read`
114+
- Core bot scopes: `commands`, `users:read`, `users:read.email`, `im:write`, `chat:write`
115+
- Optional team-read bot scopes: `groups:write`, `groups:read`
106116
- Subscribe to bot events: `app_home_opened`, `member_joined_channel`
107117

108118
Set `SLACK_CLIENT_ID`, `SLACK_CLIENT_SECRET`, `SLACK_SIGNING_SECRET`, and `MUMBL_SLACK_TOKEN_ENCRYPTION_KEY` in the deployment environment. Install through `/api/slack/install`.
109119

110-
`/mumbl room platform team` creates a Mumbl room from Slack and returns a one-time creator handoff link for opening the room in a browser. `/mumbl start platform team` remains an alias. Slack-created rooms are auto-pinned and linked to creator ownership when Mumbl can match or later connect the Slack user to a Mumbl login. `/mumbl pin platform-team` explicitly adds a Mumbl room to that Slack user's publish list without tracking room membership, and best-effort invites them into the room's Mumbl-created Slack reads channel when one exists. App Home can draft, review, edit, publish private field notes to pinned Mumbl spaces, and manage personal pinned spaces. Optional team-read Slack posting is creator-enabled per room. If a creator switches it on, Mumbl starts an optional Slack permission upgrade that asks for `chat:write`, `groups:write`, and `groups:read` so it can create one private channel, post published team reads there, and auto-pin that Mumbl room when a connected user joins the Mumbl-created Slack channel. It still does not request Slack history scopes.
120+
`/mumbl room platform team` creates a Mumbl room from Slack and returns a one-time creator handoff link for opening the room in a browser. `/mumbl start platform team` remains an alias. Slack-created rooms are auto-pinned and linked to creator ownership when Mumbl can match or later connect the Slack user to a Mumbl login. `/mumbl pin platform-team` explicitly adds a Mumbl room to that Slack user's publish list without tracking room membership, and best-effort invites them into the room's Mumbl-created Slack reads channel when one exists. App Home can draft, review, edit, publish private field notes to pinned Mumbl spaces, and manage personal pinned spaces. Optional team-read Slack posting is creator-enabled per room. If a creator switches it on, Mumbl starts an optional Slack permission upgrade that asks for `groups:write` and `groups:read` so it can create one private channel, post published team reads there using the core `chat:write` scope, and auto-pin that Mumbl room when a connected user joins the Mumbl-created Slack channel. It still does not request Slack history scopes.
111121

112122
Beta default: Slack team-read channels are private. A future public workspace channel option can use admin-approved Slack permissions such as `channels:manage`, only for creating the reads channel and still without history scopes.
113123

@@ -132,6 +142,7 @@ GitHub Actions runs `npm ci` and `npm run build` on pushes and pull requests to
132142

133143
Prompt rotation, heartbeat job queueing, rate limits, and pooler notes are documented in [docs/scaling.md](/Users/dawn/Code/mumbl-app/docs/scaling.md).
134144
Free-tier tradeoffs and future upgrade paths are documented in [docs/free-tier-compromises.md](/Users/dawn/Code/mumbl-app/docs/free-tier-compromises.md).
145+
Private pattern graph behavior, pgvector checks, and staging QA steps are documented in [docs/pattern-graph.md](/Users/dawn/Code/mumbl-app/docs/pattern-graph.md).
135146

136147
## Current Stack
137148

app/api/auth/link-session/route.js

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { badRequest, ok, serverError } from "../../../../src/server/http";
22
import { hashToken } from "../../../../src/server/hash";
33
import { resolveRequestOwner } from "../../../../src/server/auth";
4+
import { isMissingSavedRoomAccessTable } from "../../../../src/server/roomAccess";
45
import { getSupabaseAdmin } from "../../../../src/server/supabase";
56
import { cleanString } from "../../../../src/server/validation";
67

@@ -9,6 +10,7 @@ export async function POST(request) {
910
const body = await request.json();
1011
const sessionToken = cleanString(body.sessionToken, 256);
1112
const creatorTokens = parseCreatorTokens(body.creatorTokens);
13+
const roomAccessTokens = parseRoomAccessTokens(body.roomAccessTokens);
1214
const postEditTokens = parsePostEditTokens(body.postEditTokens);
1315
if (!sessionToken) return badRequest("session token is required");
1416

@@ -20,8 +22,8 @@ export async function POST(request) {
2022
linkTable({ supabase, table: "dumps", owner }),
2123
linkTable({ supabase, table: "field_notes", owner }),
2224
linkTable({ supabase, table: "public_profiles", owner, tolerateMissing: true }),
23-
linkTable({ supabase, table: "dump_insights", owner, tolerateMissing: true }),
2425
linkCreatorSpaces({ supabase, owner, creatorTokens }),
26+
linkRoomAccess({ supabase, owner, roomAccessTokens }),
2527
linkPostEditTokens({ supabase, owner, postEditTokens }),
2628
linkReactions({ supabase, owner }),
2729
]);
@@ -31,8 +33,8 @@ export async function POST(request) {
3133
dumps: updates[0],
3234
fieldNotes: updates[1],
3335
publicProfiles: updates[2],
34-
dumpInsights: updates[3],
35-
creatorSpaces: updates[4],
36+
creatorSpaces: updates[3],
37+
savedRooms: updates[4],
3638
editablePosts: updates[5],
3739
reactions: updates[6],
3840
});
@@ -63,6 +65,17 @@ function parsePostEditTokens(value) {
6365
.slice(0, 200);
6466
}
6567

68+
function parseRoomAccessTokens(value) {
69+
if (!Array.isArray(value)) return [];
70+
return value
71+
.map((item) => ({
72+
slug: cleanString(item?.slug, 64),
73+
token: cleanString(item?.token, 256),
74+
}))
75+
.filter((item) => item.slug && item.token)
76+
.slice(0, 100);
77+
}
78+
6679
async function linkCreatorSpaces({ supabase, owner, creatorTokens }) {
6780
if (!creatorTokens.length) return 0;
6881
let linked = 0;
@@ -97,6 +110,36 @@ async function linkPostEditTokens({ supabase, owner, postEditTokens }) {
97110
return linked;
98111
}
99112

113+
async function linkRoomAccess({ supabase, owner, roomAccessTokens }) {
114+
if (!roomAccessTokens.length || !owner.userId) return 0;
115+
let linked = 0;
116+
for (const item of roomAccessTokens) {
117+
const tokenHash = hashToken(item.token);
118+
const { data: space, error: spaceError } = await supabase
119+
.from("spaces")
120+
.select("id,read_token_hash")
121+
.eq("slug", item.slug)
122+
.eq("read_token_hash", tokenHash)
123+
.maybeSingle();
124+
if (spaceError) throw spaceError;
125+
if (!space?.id) continue;
126+
127+
const { error } = await supabase.from("saved_room_access").upsert(
128+
{
129+
user_id: owner.userId,
130+
space_id: space.id,
131+
read_token_hash: tokenHash,
132+
last_opened_at: new Date().toISOString(),
133+
},
134+
{ onConflict: "user_id,space_id" },
135+
);
136+
if (isMissingSavedRoomAccessTable(error)) return linked;
137+
if (error) throw error;
138+
linked += 1;
139+
}
140+
return linked;
141+
}
142+
100143
async function linkReactions({ supabase, owner }) {
101144
if (!owner.sessionTokenHash || !owner.userId) return 0;
102145
const authReactionHash = hashToken(`auth-reaction:${owner.userId}`);

app/api/cron/heartbeats/route.js

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { makeHeartbeat } from "../../../../src/lib/heartbeat";
2+
import { decryptContentFields, decryptContentRows, encryptContentFields } from "../../../../src/server/encryption";
23
import { getServerEnv } from "../../../../src/server/env";
34
import { makeHeartbeatCardFields } from "../../../../src/server/heartbeatCard";
45
import { badRequest, ok, serverError } from "../../../../src/server/http";
@@ -84,16 +85,18 @@ async function processHeartbeatJobs(supabase, weekOf) {
8485
async function generateHeartbeatForSpace(supabase, spaceId, weekOf) {
8586
const { data: space, error: spaceError } = await supabase.from("spaces").select("*").eq("id", spaceId).single();
8687
if (spaceError) throw spaceError;
88+
const readableSpace = decryptContentFields("spaces", space, ["name", "description", "public_name"]);
8789

8890
const { data: posts, error: postsError } = await supabase
8991
.from("posts")
90-
.select("id,type,content")
92+
.select("id,type,encrypted_payload")
9193
.eq("space_id", space.id)
9294
.eq("type", "field_note")
9395
.gte("created_at", weekOf + "T00:00:00.000Z");
9496
if (postsError) throw postsError;
97+
const readablePosts = decryptContentRows("posts", posts || [], ["content", "display_name", "field_note_title"]);
9598

96-
const postIds = posts.map((post) => post.id);
99+
const postIds = readablePosts.map((post) => post.id);
97100
const { data: reactions, error: reactionsError } = postIds.length
98101
? await supabase.from("reactions").select("post_id,label").in("post_id", postIds)
99102
: { data: [], error: null };
@@ -104,40 +107,42 @@ async function generateHeartbeatForSpace(supabase, spaceId, weekOf) {
104107
return counts;
105108
}, {});
106109

107-
const anonymisedPosts = posts.map((post) => ({
110+
const anonymisedPosts = readablePosts.map((post) => ({
108111
type: post.type,
109112
content: post.content,
110113
reaction_count: reactionCounts[post.id] || 0,
111114
}));
112115

113116
const heartbeat = makeHeartbeat({
114-
...space,
115-
vibe: space.vibe,
117+
...readableSpace,
118+
vibe: readableSpace.vibe,
116119
posts: anonymisedPosts.map((post) => ({
117120
type: post.type,
118121
content: post.content,
119122
reactions: { total: Array.from({ length: post.reaction_count }) },
120123
})),
121124
});
122-
const card = makeHeartbeatCardFields({ heartbeat, posts, reactions });
125+
const card = makeHeartbeatCardFields({ heartbeat, posts: readablePosts, reactions });
123126

124127
const { error: upsertError } = await supabase.from("heartbeats").upsert(
125128
{
126129
space_id: space.id,
127130
week_of: weekOf,
128-
vibe_read: heartbeat.vibeRead,
129-
digest: heartbeat.digest,
130-
uplift: heartbeat.uplift,
131-
vibe_word: card.vibeWord,
132-
top_theme: card.topTheme,
133131
energy_level: card.energyLevel,
134-
card_line: card.cardLine,
132+
encrypted_payload: encryptContentFields("heartbeats", {
133+
vibe_read: heartbeat.vibeRead,
134+
digest: heartbeat.digest,
135+
uplift: heartbeat.uplift,
136+
vibe_word: card.vibeWord,
137+
top_theme: card.topTheme,
138+
card_line: card.cardLine,
139+
}),
135140
},
136141
{ onConflict: "space_id,week_of" },
137142
);
138143
if (upsertError) throw upsertError;
139144

140-
return { slug: space.slug, posts: anonymisedPosts.length };
145+
return { slug: readableSpace.slug, posts: anonymisedPosts.length };
141146
}
142147

143148
function currentMonday() {

app/api/dumps/[dumpId]/route.js

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { badRequest, notFound, ok, serverError } from "../../../../src/server/http";
22
import { applyOwnerFilter, assertExpectedAuthenticatedOwner, resolveRequestOwner } from "../../../../src/server/auth";
3+
import { cleanupPatternGraphAfterDumpDelete } from "../../../../src/server/dumpPatterns";
4+
import { encryptContentFields } from "../../../../src/server/encryption";
35
import { makeLocalReflection, serializeDump } from "../../../../src/server/dumps";
46
import { getSupabaseAdmin } from "../../../../src/server/supabase";
57
import { cleanString } from "../../../../src/server/validation";
@@ -20,12 +22,24 @@ export async function PATCH(request, { params }) {
2022
const supabase = getSupabaseAdmin();
2123
const owner = await resolveRequestOwner({ request, sessionToken });
2224
assertExpectedAuthenticatedOwner(owner, expectsAuthenticatedOwner);
25+
const { data: existingDump, error: existingError } = await applyOwnerFilter(
26+
supabase.from("dumps").select("id,encrypted_payload").eq("id", dumpId),
27+
owner,
28+
).single();
29+
if (existingError?.code === "PGRST116") return notFound("dump not found");
30+
if (existingError) throw existingError;
31+
const aiReflection = wantsReflection ? makeLocalReflection(content) : null;
2332
const { data: dump, error } = await applyOwnerFilter(
2433
supabase
2534
.from("dumps")
2635
.update({
27-
content,
28-
ai_reflection: wantsReflection ? makeLocalReflection(content) : null,
36+
encrypted_payload: {
37+
...(existingDump.encrypted_payload || {}),
38+
...encryptContentFields("dumps", {
39+
content,
40+
ai_reflection: aiReflection,
41+
}),
42+
},
2943
updated_at: new Date().toISOString(),
3044
})
3145
.eq("id", dumpId),
@@ -58,6 +72,9 @@ export async function DELETE(request, { params }) {
5872
const { error, count } = await applyOwnerFilter(supabase.from("dumps").delete({ count: "exact" }).eq("id", dumpId), owner);
5973
if (error) throw error;
6074
if (!count) return notFound("dump not found");
75+
if (owner.userId) {
76+
await cleanupPatternGraphAfterDumpDelete({ supabase, userId: owner.userId, dumpIds: [dumpId], source: "web" });
77+
}
6178

6279
return ok({ deleted: true });
6380
} catch (error) {

0 commit comments

Comments
 (0)