jinaga-server 3.5.4 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/.github/workflows/publish.yml +5 -2
  2. package/.github/workflows/upgrade-jinaga.yml +51 -0
  3. package/CLAUDE.md +77 -0
  4. package/dist/authorization/authorization-keystore.d.ts +27 -1
  5. package/dist/authorization/authorization-keystore.js +52 -4
  6. package/dist/authorization/authorization-keystore.js.map +1 -1
  7. package/dist/http/router.d.ts +4 -1
  8. package/dist/http/router.js +157 -14
  9. package/dist/http/router.js.map +1 -1
  10. package/dist/jinaga-server.js +18 -1
  11. package/dist/jinaga-server.js.map +1 -1
  12. package/dist/memory/memory-keystore.js +1 -1
  13. package/dist/memory/memory-keystore.js.map +1 -1
  14. package/dist/postgres/query-description.d.ts +1 -0
  15. package/dist/postgres/query-description.js +34 -3
  16. package/dist/postgres/query-description.js.map +1 -1
  17. package/dist/postgres/specification-sql.js +7 -1
  18. package/dist/postgres/specification-sql.js.map +1 -1
  19. package/docs/analysis/feed-builder-nested-existential-bug.md +191 -0
  20. package/integration-test/jinaga-test/distribution-multi-given.test.js +184 -0
  21. package/integration-test/jinaga-test/distribution-negating-feed.test.js +202 -0
  22. package/integration-test/jinaga-test/feed-existential-input.test.js +241 -0
  23. package/integration-test/jinaga-test/feeds-endpoint.test.js +140 -0
  24. package/integration-test/jinaga-test/package-lock.json +4 -4
  25. package/integration-test/jinaga-test/package.json +3 -2
  26. package/integration-test/jinaga-test/subscription-anchor-arrival.test.js +129 -0
  27. package/integration-test/jinaga-test/subscription-helpers.js +60 -0
  28. package/integration-test/jinaga-test/subscription-late-auth.test.js +245 -0
  29. package/integration-test/jinaga-test/subscription-no-rule.test.js +137 -0
  30. package/package.json +2 -2
  31. package/src/authorization/authorization-keystore.ts +87 -5
  32. package/src/http/router.ts +174 -21
  33. package/src/jinaga-server.ts +36 -3
  34. package/src/memory/memory-keystore.ts +1 -1
  35. package/src/postgres/query-description.ts +66 -11
  36. package/src/postgres/specification-sql.ts +7 -1
  37. package/test/authorization/subscriptionIntersectionSpec.ts +208 -0
  38. package/test/http/anchorListenerSpec.ts +117 -0
  39. package/test/http/lateAuthRecoverySpec.ts +245 -0
  40. package/test/http/streamFeedAnchorSpec.ts +169 -0
@@ -0,0 +1,191 @@
1
+ # Bug Analysis: Feed Builder Strips Inner Negative Existential Conditions
2
+
3
+ **Date:** 2026-03-30
4
+ **Area:** `jinaga` package — `feed-builder.js` / `specification-sql.ts`
5
+ **Symptom:** EventName facts not delivered to clients for events that have been deleted and restored
6
+
7
+ ---
8
+
9
+ ## Observed Symptom
10
+
11
+ The LaunchKings admin portal Events dropdown shows the raw fact hash prefix (e.g. `65bc55e`) instead of the event's human-readable name ("LATAM test"). The `CodeLaunch.Event.Name` fact exists in the database and the distribution rules authorize it, but it is never delivered to the client via feeds.
12
+
13
+ ## Database State That Triggers the Bug
14
+
15
+ ```
16
+ CodeLaunch.Event 1 fact — id: 65bc55e9-495a-4418-9744-3dea6f8c8f2a
17
+ CodeLaunch.Event.Name 1 fact — value: "LATAM test" (EXISTS — not missing)
18
+ CodeLaunch.Event.Delete 2 facts — deleted twice
19
+ CodeLaunch.Event.Restore 2 facts — restored twice → event is currently active
20
+ ```
21
+
22
+ The event is logically active (every delete has a matching restore), but its presence of any `EventDelete` facts triggers the bug.
23
+
24
+ ---
25
+
26
+ ## Root Cause
27
+
28
+ ### The Specification
29
+
30
+ The `eventsListInTenant` specification uses `Event.in(tenant)`:
31
+
32
+ ```typescript
33
+ static in(tenant: LabelOf<Tenant>) {
34
+ return tenant.successors(Event, event => event.tenant)
35
+ .notExists(event =>
36
+ event.successors(EventDelete, d => d.event)
37
+ .notExists(d =>
38
+ d.successors(EventRestore, r => r.eventDelete)
39
+ )
40
+ );
41
+ }
42
+ ```
43
+
44
+ The full `!E` condition is:
45
+ ```
46
+ !E { EventDelete [ u2->event = u1, !E { EventRestore [ u3->eventDelete = u2 ] } ] }
47
+ ```
48
+ Meaning: *no un-restored EventDelete exists for this event*.
49
+
50
+ ### What `buildFeeds` Produces
51
+
52
+ `buildFeeds` calls `buildExistentialCondition` to create a simplified version of negative existential conditions for use in feed specifications. The relevant code:
53
+
54
+ ```js
55
+ // feed-builder.js — buildExistentialCondition()
56
+ for (const match of matches) {
57
+ existentialCondition = {
58
+ ...existentialCondition,
59
+ matches: [...existentialCondition.matches, {
60
+ ...match,
61
+ conditions: match.conditions.filter(isPathCondition) // strips ALL existential conditions
62
+ }]
63
+ };
64
+
65
+ for (const innerExistentialCondition of match.conditions.filter(isExistentialCondition)) {
66
+ if (innerExistentialCondition.exists) {
67
+ // ✓ positive inner existentials are recursively included
68
+ }
69
+ // ✗ negative inner existentials (exists=false) are silently dropped
70
+ }
71
+ }
72
+ ```
73
+
74
+ The inner `!E{EventRestore}` condition has `exists=false` and is therefore **dropped**. The simplified condition produced is:
75
+
76
+ ```
77
+ !E { EventDelete [ u2->event = u1 ] }
78
+ ```
79
+ Meaning: *no EventDelete at all*.
80
+
81
+ ### The Five Feeds for `eventsListInTenant`
82
+
83
+ `buildFeeds` produces these feeds:
84
+
85
+ | Feed | Purpose | Condition on Event |
86
+ |---|---|---|
87
+ | FeedA | Detect EventRestore arrivals | none (bare join) |
88
+ | FeedB | Detect EventDelete arrivals | none (bare join) |
89
+ | FeedC | Deliver Event facts | `!E{EventDelete}` ← simplified |
90
+ | FeedD | Detect EventName supersession | `!E{EventDelete}` ← inherited |
91
+ | FeedE | **Deliver EventName facts** | `!E{EventDelete}` ← **BUG** |
92
+
93
+ FeedE inherits FeedC's simplified condition via `addProjections(FeedC, ...)`. FeedE's SQL is therefore:
94
+
95
+ ```sql
96
+ SELECT f_name.hash, sort(array[f_name.fact_id], 'desc') as bookmark
97
+ FROM fact f_tenant
98
+ JOIN edge e1 ON e1.predecessor_fact_id = f_tenant.fact_id AND e1.role_id = $event_tenant_role
99
+ JOIN fact f_event ON f_event.fact_id = e1.successor_fact_id
100
+ JOIN edge e2 ON e2.predecessor_fact_id = f_event.fact_id AND e2.role_id = $eventname_event_role
101
+ JOIN fact f_name ON f_name.fact_id = e2.successor_fact_id
102
+ WHERE f_tenant.fact_type_id = $tenant_type AND f_tenant.hash = $tenant_hash
103
+ AND NOT EXISTS (
104
+ SELECT 1 FROM edge e_del
105
+ WHERE e_del.predecessor_fact_id = f_event.fact_id
106
+ AND e_del.role_id = $eventdelete_event_role
107
+ -- ↑ checks for ANY EventDelete, not just un-restored ones
108
+ )
109
+ AND NOT EXISTS (
110
+ SELECT 1 FROM edge e_prior
111
+ WHERE e_prior.predecessor_fact_id = f_name.fact_id
112
+ AND e_prior.role_id = $eventname_prior_role
113
+ )
114
+ AND sort(array[f_name.fact_id], 'desc') > $bookmark
115
+ ORDER BY bookmark ASC LIMIT 100
116
+ ```
117
+
118
+ Because the "LATAM test" event has `EventDelete` facts (regardless of `EventRestore`), the `NOT EXISTS (EventDelete)` clause is false, the event is excluded, and **the query returns zero rows**.
119
+
120
+ ### Why the Listener Doesn't Rescue It
121
+
122
+ The observer also registers inverse specification listeners. When FeedA delivers `EventRestore` facts, the listener fires and calls `notifyAdded` — which reads EventName from the **local IndexedDB store**. Because FeedE never delivered EventName to the local store, the re-evaluation finds nothing, and names remain absent.
123
+
124
+ ---
125
+
126
+ ## Impact
127
+
128
+ Any event that has ever been deleted (even if subsequently restored) will have its name and other projection facts permanently withheld from all projection feeds, while the base `Event` fact itself still appears (for example via FeedC). This affects:
129
+
130
+ - `CodeLaunch.Event.Name` — event name not shown in nav dropdown
131
+ - `CodeLaunch.Event.Date` — event dates not shown
132
+ - Any other projected facts on events from `eventsListInTenant` or similar specs
133
+
134
+ In this environment the event was deleted and restored twice, making it the only event and confirming 100% reproduction.
135
+
136
+ ---
137
+
138
+ ## The Fix
139
+
140
+ ### Option A — Support Nested NOT EXISTS in Feed SQL (Correct but Complex)
141
+
142
+ Extend `buildExistentialCondition` to recursively include negative inner existential conditions:
143
+
144
+ ```js
145
+ // feed-builder.js — buildExistentialCondition()
146
+ for (const innerExistentialCondition of match.conditions.filter(isExistentialCondition)) {
147
+ if (innerExistentialCondition.exists) {
148
+ // existing logic — keep positive existentials
149
+ const { existentialCondition: newEC, ... } = buildExistentialCondition(...);
150
+ existentialCondition = newEC;
151
+ } else {
152
+ // NEW: also recurse into negative existentials
153
+ const { existentialCondition: newEC, ... } = buildExistentialCondition(
154
+ innerExistentialCondition, innerExistentialCondition.matches, ...
155
+ );
156
+ existentialCondition = { ...existentialCondition, innerConditions: [..., newEC] };
157
+ }
158
+ }
159
+ ```
160
+
161
+ This also requires:
162
+ 1. Adding a `innerConditions` (or `notExistsConditions`) field to `ExistentialConditionDescription`
163
+ 2. Updating `generateNotExistsWhereClause` in `specification-sql.ts` to recurse into nested conditions
164
+ 3. Updating `notExistsConditionsEqual` in `distribution-engine.ts` if nested conditions need to be compared
165
+
166
+ ### Option B — Omit Outer Not-Exists from Projection Feeds (Simpler)
167
+
168
+ When `addProjections` builds projection feeds (FeedD, FeedE), start from a version of the specification that **drops all not-exists conditions on the base matches**. Projection feeds would then return facts for all events (active or not), and the full specification read handles the correct filtering.
169
+
170
+ ```js
171
+ // feed-builder.js — buildFeeds()
172
+ const baseFeed = stripNotExistsConditions(finalFeed); // new helper
173
+ const feedsWithProjections = addProjections(baseFeed, unusedGivens, specification.projection.components);
174
+ ```
175
+
176
+ This is safe because:
177
+ - FeedA and FeedB already handle re-evaluation when delete/restore facts arrive
178
+ - The actual filtering is done by `factManager.read()` using the full specification
179
+ - Feeds are designed to be a **superset** of relevant facts; over-delivery is intentional
180
+
181
+ Option B is simpler, has no SQL changes, and aligns with the documented design principle that "feeds are not executed with nested existential conditions."
182
+
183
+ ---
184
+
185
+ ## Files Involved
186
+
187
+ | File | Role |
188
+ |---|---|
189
+ | `jinaga/src/specification/feed-builder.ts` | `buildExistentialCondition` — drops inner `!E` conditions |
190
+ | `src/postgres/specification-sql.ts` | `generateNotExistsWhereClause` — only handles flat conditions |
191
+ | `jinaga/src/distribution/distribution-engine.ts` | `notExistsConditionsEqual` — explicitly skips nested comparison |
@@ -0,0 +1,184 @@
1
+ const request = require('supertest');
2
+ const { buildModel, User } = require('jinaga');
3
+ const { createSubscriptionApp, asUser, randomSuffix } = require('./subscription-helpers');
4
+
5
+ // Exercises a distribution rule whose share/with specs have multiple
6
+ // `given` labels — the "multi-given distribution rule (#161)" dimension
7
+ // the PR claims is closed by canAuthorizeByComposition.
8
+ //
9
+ // The model intentionally pulls BOTH givens into every match clause
10
+ // (Task has predecessors on Tenant *and* Project, ProjectAccess has
11
+ // predecessors on Tenant, Project, and User). This avoids a separate
12
+ // PostgresStore SQL-builder bug that surfaces when a given is referenced
13
+ // only in a WHERE clause with no JOIN — that issue is unrelated to the
14
+ // distribution-engine work in PR #163 and is worth a dedicated bug
15
+ // report rather than masking here.
16
+
17
+ class Tenant {
18
+ static Type = "MultiGiven.Tenant";
19
+ type = Tenant.Type;
20
+ constructor(creator, identifier) {
21
+ this.creator = creator;
22
+ this.identifier = identifier;
23
+ }
24
+ }
25
+
26
+ class Project {
27
+ static Type = "MultiGiven.Project";
28
+ type = Project.Type;
29
+ constructor(tenant, identifier) {
30
+ this.tenant = tenant;
31
+ this.identifier = identifier;
32
+ }
33
+ }
34
+
35
+ class Task {
36
+ static Type = "MultiGiven.Task";
37
+ type = Task.Type;
38
+ constructor(tenant, project, description) {
39
+ this.tenant = tenant;
40
+ this.project = project;
41
+ this.description = description;
42
+ }
43
+ }
44
+
45
+ class ProjectAccess {
46
+ static Type = "MultiGiven.ProjectAccess";
47
+ type = ProjectAccess.Type;
48
+ constructor(tenant, project, user) {
49
+ this.tenant = tenant;
50
+ this.project = project;
51
+ this.user = user;
52
+ }
53
+ }
54
+
55
+ const model = buildModel(b => b
56
+ .type(User)
57
+ .type(Tenant, m => m.predecessor("creator", User))
58
+ .type(Project, m => m.predecessor("tenant", Tenant))
59
+ .type(Task, m => m
60
+ .predecessor("tenant", Tenant)
61
+ .predecessor("project", Project))
62
+ .type(ProjectAccess, m => m
63
+ .predecessor("tenant", Tenant)
64
+ .predecessor("project", Project)
65
+ .predecessor("user", User))
66
+ );
67
+
68
+ function authorization(a) {
69
+ return a
70
+ .any(User)
71
+ .any(Tenant)
72
+ .any(Project)
73
+ .any(Task)
74
+ .any(ProjectAccess);
75
+ }
76
+
77
+ // Both share and with use a 2-given (Tenant, Project) and reference
78
+ // both labels in their match clauses.
79
+ function distribution(d) {
80
+ return d
81
+ .share(model.given(Tenant, Project).match((tenant, project, facts) =>
82
+ facts.ofType(Task)
83
+ .join(t => t.tenant, tenant)
84
+ .join(t => t.project, project)
85
+ ))
86
+ .with(model.given(Tenant, Project).match((tenant, project, facts) =>
87
+ facts.ofType(ProjectAccess)
88
+ .join(a => a.tenant, tenant)
89
+ .join(a => a.project, project)
90
+ .selectMany(a => facts.ofType(User).join(u => u, a.user))
91
+ ));
92
+ }
93
+
94
+ function taskSpecText(tenantHash, projectHash) {
95
+ return `let p1: ${Tenant.Type} = #${tenantHash}\n` +
96
+ `let p2: ${Project.Type} = #${projectHash}\n` +
97
+ `(p1: ${Tenant.Type}, p2: ${Project.Type}) {\n` +
98
+ ` t: ${Task.Type} [\n` +
99
+ ` t->tenant: ${Tenant.Type} = p1\n` +
100
+ ` t->project: ${Project.Type} = p2\n` +
101
+ ` ]\n` +
102
+ `} => t`;
103
+ }
104
+
105
+ describe('Distribution rule with multi-given share/with', () => {
106
+ let app, withSession, close;
107
+ let tenantValue, projectValue, tenantHash, projectHash, taskHash;
108
+ let creatorId;
109
+
110
+ beforeEach(async () => {
111
+ ({ app, withSession, close } = await createSubscriptionApp({
112
+ model, authorization, distribution
113
+ }));
114
+
115
+ creatorId = 'mg-creator-' + randomSuffix();
116
+ ({ tenantHash, projectHash, taskHash } = await asUser(withSession, creatorId, async (j) => {
117
+ const { userFact: creatorFact } = await j.login();
118
+ const tenant = await j.fact(new Tenant(creatorFact, 't-' + randomSuffix()));
119
+ tenantValue = tenant;
120
+ const project = await j.fact(new Project(tenant, 'p-' + randomSuffix()));
121
+ projectValue = project;
122
+ const task = await j.fact(new Task(tenant, project, 'do the thing'));
123
+ return {
124
+ tenantHash: j.hash(tenant),
125
+ projectHash: j.hash(project),
126
+ taskHash: j.hash(task)
127
+ };
128
+ }));
129
+ });
130
+
131
+ afterEach(async () => {
132
+ if (close) await close();
133
+ });
134
+
135
+ it('delivers tasks to a user with ProjectAccess through a 2-given subscription', async () => {
136
+ const memberId = 'mg-member-' + randomSuffix();
137
+ const memberFact = await asUser(withSession, memberId, async (j) => {
138
+ const { userFact } = await j.login();
139
+ return userFact;
140
+ });
141
+
142
+ // Grant ProjectAccess that covers both the tenant and the project.
143
+ await asUser(withSession, creatorId, async (j) => {
144
+ await j.fact(new ProjectAccess(tenantValue, projectValue, memberFact));
145
+ });
146
+
147
+ const feedsResponse = await request(app)
148
+ .post('/feeds')
149
+ .set('Content-Type', 'text/plain')
150
+ .set('x-test-user-id', memberId)
151
+ .send(taskSpecText(tenantHash, projectHash));
152
+ expect(feedsResponse.status).toBe(200);
153
+ const { feeds } = JSON.parse(feedsResponse.text);
154
+ expect(feeds.length).toBeGreaterThan(0);
155
+
156
+ const pollResponse = await request(app)
157
+ .get(`/feeds/${feeds[0]}`)
158
+ .set('x-test-user-id', memberId);
159
+ expect(pollResponse.status).toBe(200);
160
+ const body = JSON.parse(pollResponse.text);
161
+ const taskRefs = (body.references || []).filter(r => r.type === Task.Type);
162
+ expect(taskRefs.map(r => r.hash)).toContain(taskHash);
163
+ });
164
+
165
+ it('hides tasks from a user without ProjectAccess through the same 2-given subscription', async () => {
166
+ const outsiderId = 'mg-outsider-' + randomSuffix();
167
+ await asUser(withSession, outsiderId, async (j) => { /* login only */ });
168
+
169
+ const feedsResponse = await request(app)
170
+ .post('/feeds')
171
+ .set('Content-Type', 'text/plain')
172
+ .set('x-test-user-id', outsiderId)
173
+ .send(taskSpecText(tenantHash, projectHash));
174
+ expect(feedsResponse.status).toBe(200);
175
+ const { feeds } = JSON.parse(feedsResponse.text);
176
+
177
+ const pollResponse = await request(app)
178
+ .get(`/feeds/${feeds[0]}`)
179
+ .set('x-test-user-id', outsiderId);
180
+ expect(pollResponse.status).toBe(200);
181
+ const body = JSON.parse(pollResponse.text);
182
+ expect((body.references || []).filter(r => r.type === Task.Type)).toHaveLength(0);
183
+ });
184
+ });
@@ -0,0 +1,202 @@
1
+ const request = require('supertest');
2
+ const { buildModel, User } = require('jinaga');
3
+ const { createSubscriptionApp, asUser, randomSuffix } = require('./subscription-helpers');
4
+
5
+ // Exercises a distribution rule whose user-spec uses notExists — the
6
+ // "negating-feed authorization (#196)" dimension the PR closes by
7
+ // having DistributionEngine.canDistributeTo fall back to
8
+ // canAuthorizeByComposition. We avoid the late-auth/intersection path
9
+ // here so the test is hitting canDistributeTo (via feedWithDistribution)
10
+ // directly with a negating user-spec.
11
+
12
+ jest.setTimeout(30000);
13
+
14
+ class Group {
15
+ static Type = "NegatingFeed.Group";
16
+ type = Group.Type;
17
+ constructor(creator, name) {
18
+ this.creator = creator;
19
+ this.name = name;
20
+ }
21
+ }
22
+
23
+ class Member {
24
+ static Type = "NegatingFeed.Member";
25
+ type = Member.Type;
26
+ constructor(group, user) {
27
+ this.group = group;
28
+ this.user = user;
29
+ }
30
+ }
31
+
32
+ class MemberRemoved {
33
+ static Type = "NegatingFeed.Member.Removed";
34
+ type = MemberRemoved.Type;
35
+ constructor(member) {
36
+ this.member = member;
37
+ }
38
+ }
39
+
40
+ class Content {
41
+ static Type = "NegatingFeed.Content";
42
+ type = Content.Type;
43
+ constructor(group, body) {
44
+ this.group = group;
45
+ this.body = body;
46
+ }
47
+ }
48
+
49
+ const model = buildModel(b => b
50
+ .type(User)
51
+ .type(Group, m => m.predecessor("creator", User))
52
+ .type(Member, m => m
53
+ .predecessor("group", Group)
54
+ .predecessor("user", User))
55
+ .type(MemberRemoved, m => m.predecessor("member", Member))
56
+ .type(Content, m => m.predecessor("group", Group))
57
+ );
58
+
59
+ function authorization(a) {
60
+ return a
61
+ .any(User)
62
+ .any(Group)
63
+ .any(Member)
64
+ .any(MemberRemoved)
65
+ .any(Content);
66
+ }
67
+
68
+ // Share Content of a Group with users who are currently Members — i.e.
69
+ // have a Member fact with no MemberRemoved successor. The notExists
70
+ // here is what makes this a "negating-feed" user-spec.
71
+ function distribution(d) {
72
+ return d
73
+ .share(model.given(Group).match((g, facts) =>
74
+ facts.ofType(Content).join(c => c.group, g)
75
+ ))
76
+ .with(model.given(Group).match((g, facts) =>
77
+ facts.ofType(Member)
78
+ .join(m => m.group, g)
79
+ .notExists(m => facts.ofType(MemberRemoved).join(r => r.member, m))
80
+ .selectMany(m => facts.ofType(User).join(u => u, m.user))
81
+ ));
82
+ }
83
+
84
+ function contentSpecText(groupHash) {
85
+ return `let p1: ${Group.Type} = #${groupHash}\n` +
86
+ `(p1: ${Group.Type}) {\n` +
87
+ ` c: ${Content.Type} [\n` +
88
+ ` c->group: ${Group.Type} = p1\n` +
89
+ ` ]\n` +
90
+ `} => c`;
91
+ }
92
+
93
+ describe('Distribution rule with negating feed (notExists)', () => {
94
+ let app, withSession, close;
95
+ let groupValue, groupHash, contentHash;
96
+ let creatorId;
97
+
98
+ beforeEach(async () => {
99
+ ({ app, withSession, close } = await createSubscriptionApp({
100
+ model, authorization, distribution
101
+ }));
102
+
103
+ creatorId = 'group-creator-' + randomSuffix();
104
+ ({ groupHash, contentHash } = await asUser(withSession, creatorId, async (creatorJ) => {
105
+ const { userFact: creatorFact } = await creatorJ.login();
106
+ const group = await creatorJ.fact(new Group(creatorFact, 'g-' + randomSuffix()));
107
+ groupValue = group;
108
+ const content = await creatorJ.fact(new Content(group, 'shared body'));
109
+ return {
110
+ groupHash: creatorJ.hash(group),
111
+ contentHash: creatorJ.hash(content)
112
+ };
113
+ }));
114
+ });
115
+
116
+ afterEach(async () => {
117
+ if (close) await close();
118
+ });
119
+
120
+ it('delivers content to a current member', async () => {
121
+ const memberId = 'group-member-' + randomSuffix();
122
+ const memberFact = await asUser(withSession, memberId, async (j) => {
123
+ const { userFact } = await j.login();
124
+ return userFact;
125
+ });
126
+
127
+ // Grant membership BEFORE subscribing so the distribution check
128
+ // (notExists removed → current member) passes and we never hit
129
+ // intersection. This makes the test target canDistributeTo
130
+ // directly via feedWithDistribution.
131
+ await asUser(withSession, creatorId, async (j) => {
132
+ await j.fact(new Member(groupValue, memberFact));
133
+ });
134
+
135
+ const feedsResponse = await request(app)
136
+ .post('/feeds')
137
+ .set('Content-Type', 'text/plain')
138
+ .set('x-test-user-id', memberId)
139
+ .send(contentSpecText(groupHash));
140
+ expect(feedsResponse.status).toBe(200);
141
+ const { feeds } = JSON.parse(feedsResponse.text);
142
+
143
+ const pollResponse = await request(app)
144
+ .get(`/feeds/${feeds[0]}`)
145
+ .set('x-test-user-id', memberId);
146
+ expect(pollResponse.status).toBe(200);
147
+ const body = JSON.parse(pollResponse.text);
148
+ const visible = (body.references || []).filter(r => r.type === Content.Type);
149
+ expect(visible.map(r => r.hash)).toContain(contentHash);
150
+ });
151
+
152
+ it('hides content from a removed member', async () => {
153
+ const memberId = 'group-removed-' + randomSuffix();
154
+ const memberFact = await asUser(withSession, memberId, async (j) => {
155
+ const { userFact } = await j.login();
156
+ return userFact;
157
+ });
158
+
159
+ // Add then remove the membership. The notExists branch flips,
160
+ // canDistributeTo denies, and the poll returns an empty page.
161
+ let memberValue;
162
+ await asUser(withSession, creatorId, async (j) => {
163
+ memberValue = await j.fact(new Member(groupValue, memberFact));
164
+ await j.fact(new MemberRemoved(memberValue));
165
+ });
166
+
167
+ const feedsResponse = await request(app)
168
+ .post('/feeds')
169
+ .set('Content-Type', 'text/plain')
170
+ .set('x-test-user-id', memberId)
171
+ .send(contentSpecText(groupHash));
172
+ expect(feedsResponse.status).toBe(200);
173
+ const { feeds } = JSON.parse(feedsResponse.text);
174
+
175
+ const pollResponse = await request(app)
176
+ .get(`/feeds/${feeds[0]}`)
177
+ .set('x-test-user-id', memberId);
178
+ expect(pollResponse.status).toBe(200);
179
+ const body = JSON.parse(pollResponse.text);
180
+ expect((body.references || []).filter(r => r.type === Content.Type)).toHaveLength(0);
181
+ });
182
+
183
+ it('hides content from a never-member', async () => {
184
+ const outsiderId = 'group-outsider-' + randomSuffix();
185
+ await asUser(withSession, outsiderId, async (j) => { /* login only */ });
186
+
187
+ const feedsResponse = await request(app)
188
+ .post('/feeds')
189
+ .set('Content-Type', 'text/plain')
190
+ .set('x-test-user-id', outsiderId)
191
+ .send(contentSpecText(groupHash));
192
+ expect(feedsResponse.status).toBe(200);
193
+ const { feeds } = JSON.parse(feedsResponse.text);
194
+
195
+ const pollResponse = await request(app)
196
+ .get(`/feeds/${feeds[0]}`)
197
+ .set('x-test-user-id', outsiderId);
198
+ expect(pollResponse.status).toBe(200);
199
+ const body = JSON.parse(pollResponse.text);
200
+ expect((body.references || []).filter(r => r.type === Content.Type)).toHaveLength(0);
201
+ });
202
+ });