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,208 @@
1
+ import {
2
+ AuthorizationRules,
3
+ buildModel,
4
+ dehydrateFact,
5
+ DistributionRules,
6
+ FactManager,
7
+ MemoryStore,
8
+ NetworkNoOp,
9
+ ObservableSource,
10
+ PassThroughFork,
11
+ ReferencesByName,
12
+ User
13
+ } from "jinaga";
14
+
15
+ import { AuthorizationKeystore } from "../../src/authorization/authorization-keystore";
16
+ import { MemoryKeystore } from "../../src/memory/memory-keystore";
17
+
18
+ class Company {
19
+ public static Type = "Company" as const;
20
+ public type = Company.Type;
21
+ constructor(public creator: User, public name: string) { }
22
+ }
23
+
24
+ class Office {
25
+ public static Type = "Office" as const;
26
+ public type = Office.Type;
27
+ constructor(public company: Company, public name: string) { }
28
+ }
29
+
30
+ class Administrator {
31
+ public static Type = "Administrator" as const;
32
+ public type = Administrator.Type;
33
+ constructor(public company: Company, public user: User, public createdAt: Date | string) { }
34
+ }
35
+
36
+ const model = buildModel(b => b
37
+ .type(User)
38
+ .type(Company, m => m.predecessor("creator", User))
39
+ .type(Office, m => m.predecessor("company", Company))
40
+ .type(Administrator, m => m
41
+ .predecessor("company", Company)
42
+ .predecessor("user", User))
43
+ );
44
+
45
+ const subscriberIdentity = { provider: "mock", id: "subscriber" };
46
+ const creatorIdentity = { provider: "mock", id: "creator" };
47
+
48
+ describe("AuthorizationKeystore.verifyDistributionOrIntersect", () => {
49
+ it("returns the original spec when the user is already authorized", async () => {
50
+ const setup = await givenAuthorizationWithDistribution(true, async (subscriber, creator) => {
51
+ const company = new Company(creator, "Acme");
52
+ const admin = new Administrator(company, subscriber, new Date("2026-05-26"));
53
+ return { company, admin };
54
+ });
55
+
56
+ const officeSpec = model.given(Company).match((c, facts) =>
57
+ facts.ofType(Office).join(o => o.company, c)
58
+ ).specification;
59
+ const companyRef = dehydrateFact(setup.seeded.company)
60
+ .find(f => f.type === Company.Type)!;
61
+ const namedStart: ReferencesByName = { [officeSpec.given[0].label.name]: companyRef };
62
+
63
+ const result = await setup.authorization.verifyDistributionOrIntersect(
64
+ subscriberIdentity, officeSpec, namedStart);
65
+
66
+ expect(result.type).toBe("success");
67
+ if (result.type !== "success") return;
68
+ expect(result.branches).toHaveLength(1);
69
+ expect(result.branches[0].specification).toBe(officeSpec);
70
+ expect(result.branches[0].start).toEqual([companyRef]);
71
+ });
72
+
73
+ it("returns intersected branches when the user is not yet authorized", async () => {
74
+ const setup = await givenAuthorizationWithDistribution(true, async (subscriber, creator) => {
75
+ const company = new Company(creator, "Acme");
76
+ // No Administrator yet — subscriber is not authorized.
77
+ return { company };
78
+ });
79
+
80
+ const officeSpec = model.given(Company).match((c, facts) =>
81
+ facts.ofType(Office).join(o => o.company, c)
82
+ ).specification;
83
+ const companyRef = dehydrateFact(setup.seeded.company)
84
+ .find(f => f.type === Company.Type)!;
85
+ const namedStart: ReferencesByName = { [officeSpec.given[0].label.name]: companyRef };
86
+
87
+ const result = await setup.authorization.verifyDistributionOrIntersect(
88
+ subscriberIdentity, officeSpec, namedStart);
89
+
90
+ // Intersection produced an alternate branch that lifts the rule's
91
+ // user-spec into the subscription. The intersected spec has an
92
+ // additional synthetic given and a different shape than the original.
93
+ expect(result.type).toBe("success");
94
+ if (result.type !== "success") return;
95
+ expect(result.branches.length).toBeGreaterThanOrEqual(1);
96
+ expect(result.branches[0].specification).not.toBe(officeSpec);
97
+ expect(result.branches[0].specification.given.length).toBe(2);
98
+ // The synthetic given is bound to the subscriber's user fact.
99
+ expect(result.branches[0].start[1]).toEqual({
100
+ type: setup.subscriberFact.type,
101
+ hash: setup.subscriberFact.hash
102
+ });
103
+ });
104
+
105
+ it("returns denied when no distribution rule applies to the spec", async () => {
106
+ const setup = await givenAuthorizationWithDistribution(true, async (_subscriber, creator) => {
107
+ const company = new Company(creator, "Acme");
108
+ return { company };
109
+ });
110
+
111
+ // A spec the rule doesn't cover at all (different fact type).
112
+ const unrelatedSpec = model.given(Company).match((c, facts) =>
113
+ facts.ofType(Administrator).join(a => a.company, c)
114
+ ).specification;
115
+ const companyRef = dehydrateFact(setup.seeded.company)
116
+ .find(f => f.type === Company.Type)!;
117
+ const namedStart: ReferencesByName = { [unrelatedSpec.given[0].label.name]: companyRef };
118
+
119
+ const result = await setup.authorization.verifyDistributionOrIntersect(
120
+ subscriberIdentity, unrelatedSpec, namedStart);
121
+
122
+ expect(result.type).toBe("denied");
123
+ if (result.type !== "denied") return;
124
+ expect(result.reason).toBeTruthy();
125
+ });
126
+
127
+ it("returns a passthrough branch when there are no distribution rules at all", async () => {
128
+ const setup = await givenAuthorizationWithDistribution(false, async (_subscriber, creator) => {
129
+ const company = new Company(creator, "Acme");
130
+ return { company };
131
+ });
132
+
133
+ const officeSpec = model.given(Company).match((c, facts) =>
134
+ facts.ofType(Office).join(o => o.company, c)
135
+ ).specification;
136
+ const companyRef = dehydrateFact(setup.seeded.company)
137
+ .find(f => f.type === Company.Type)!;
138
+ const namedStart: ReferencesByName = { [officeSpec.given[0].label.name]: companyRef };
139
+
140
+ const result = await setup.authorization.verifyDistributionOrIntersect(
141
+ subscriberIdentity, officeSpec, namedStart);
142
+
143
+ expect(result.type).toBe("success");
144
+ if (result.type !== "success") return;
145
+ expect(result.branches).toHaveLength(1);
146
+ expect(result.branches[0].specification).toBe(officeSpec);
147
+ });
148
+ });
149
+
150
+ async function givenAuthorizationWithDistribution<T extends Record<string, any>>(
151
+ withDistributionRules: boolean,
152
+ buildFacts: (subscriber: User, creator: User) => Promise<T>
153
+ ) {
154
+ const storage = new MemoryStore();
155
+ const keystore = new MemoryKeystore();
156
+
157
+ const subscriberFact = await keystore.getOrCreateUserFact(subscriberIdentity);
158
+ const creatorFact = await keystore.getOrCreateUserFact(creatorIdentity);
159
+ const subscriber = new User(subscriberFact.fields.publicKey);
160
+ const creator = new User(creatorFact.fields.publicKey);
161
+
162
+ const seeded = await buildFacts(subscriber, creator);
163
+ const facts = [
164
+ ...dehydrateFact(subscriber),
165
+ ...dehydrateFact(creator),
166
+ ...Object.values(seeded).flatMap(v => dehydrateFact(v))
167
+ ];
168
+ // Dedupe by hash+type to avoid double-saving the shared predecessors.
169
+ const seenKeys = new Set<string>();
170
+ const uniqueFacts = facts.filter(f => {
171
+ const k = `${f.type}|${f.hash}`;
172
+ if (seenKeys.has(k)) return false;
173
+ seenKeys.add(k);
174
+ return true;
175
+ });
176
+ await storage.save(uniqueFacts.map(f => ({ fact: f, signatures: [] })));
177
+
178
+ const fork = new PassThroughFork(storage);
179
+ const observable = new ObservableSource(storage);
180
+ const network = new NetworkNoOp();
181
+ const factManager = new FactManager(fork, observable, storage, network, []);
182
+
183
+ const authorizationRules = new AuthorizationRules(model)
184
+ .no(User)
185
+ .any(Company)
186
+ .any(Office)
187
+ .any(Administrator);
188
+
189
+ const distributionRules = withDistributionRules
190
+ ? new DistributionRules([])
191
+ .share(model.given(Company).match((c, facts) =>
192
+ facts.ofType(Office).join(o => o.company, c)
193
+ ))
194
+ .with(model.given(Company).match((c, facts) =>
195
+ facts.ofType(Administrator)
196
+ .join(a => a.company, c)
197
+ .selectMany(a => facts.ofType(User).join(u => u, a.user))
198
+ ))
199
+ : null;
200
+
201
+ const authorization = new AuthorizationKeystore(
202
+ factManager, storage, keystore, authorizationRules, distributionRules);
203
+ return {
204
+ authorization,
205
+ subscriberFact,
206
+ seeded: seeded as T & { company: Company }
207
+ };
208
+ }
@@ -0,0 +1,117 @@
1
+ import {
2
+ buildModel,
3
+ dehydrateFact,
4
+ FactManager,
5
+ MemoryStore,
6
+ NetworkNoOp,
7
+ ObservableSource,
8
+ PassThroughFork,
9
+ Specification,
10
+ User
11
+ } from "jinaga";
12
+
13
+ // Validates the same listener pattern HttpRouter.streamFeed uses to wake
14
+ // subscriptions when an anchor (given) fact finally arrives at the
15
+ // server. The pattern is: register a SpecificationListener on a spec
16
+ // whose only given matches the anchor's type and whose matches array is
17
+ // empty, then filter the projected results by the anchor hash.
18
+ class Note {
19
+ public static Type = "anchor.Note" as const;
20
+ public type = Note.Type;
21
+ constructor(public author: User, public body: string) { }
22
+ }
23
+
24
+ const model = buildModel(b => b
25
+ .type(User)
26
+ .type(Note, m => m.predecessor("author", User))
27
+ );
28
+
29
+ function makeFactManager() {
30
+ const storage = new MemoryStore();
31
+ const fork = new PassThroughFork(storage);
32
+ const observable = new ObservableSource(storage);
33
+ const network = new NetworkNoOp();
34
+ const factManager = new FactManager(fork, observable, storage, network, []);
35
+ return { storage, factManager };
36
+ }
37
+
38
+ describe("anchor listener pattern", () => {
39
+ it("fires when a fact matching the anchor's (type, hash) is saved", async () => {
40
+ const { factManager } = makeFactManager();
41
+
42
+ const author = new User("anchor-author-key");
43
+ const authorRef = dehydrateFact(author)[0];
44
+
45
+ let firedFor: string | null = null;
46
+ const anchorSpec: Specification = {
47
+ given: [{ label: { name: "x", type: authorRef.type }, conditions: [] }],
48
+ matches: [],
49
+ projection: { type: "fact", label: "x" }
50
+ };
51
+ const listener = factManager.addSpecificationListener(anchorSpec, async (results) => {
52
+ for (const r of results) {
53
+ const ref = (r.tuple as any).x;
54
+ if (ref && ref.type === authorRef.type && ref.hash === authorRef.hash) {
55
+ firedFor = ref.hash;
56
+ }
57
+ }
58
+ });
59
+
60
+ await factManager.save(dehydrateFact(author).map(f => ({ fact: f, signatures: [] })));
61
+
62
+ factManager.removeSpecificationListener(listener);
63
+ expect(firedFor).toBe(authorRef.hash);
64
+ });
65
+
66
+ it("does not fire for facts of an unrelated type", async () => {
67
+ const { factManager } = makeFactManager();
68
+
69
+ const author = new User("anchor-author-key");
70
+ const note = new Note(author, "hi");
71
+ const noteRef = dehydrateFact(note).find(f => f.type === Note.Type)!;
72
+
73
+ let fired = false;
74
+ const anchorSpec: Specification = {
75
+ given: [{ label: { name: "x", type: noteRef.type }, conditions: [] }],
76
+ matches: [],
77
+ projection: { type: "fact", label: "x" }
78
+ };
79
+ const listener = factManager.addSpecificationListener(anchorSpec, async () => {
80
+ fired = true;
81
+ });
82
+
83
+ // Save a fact of a different type only.
84
+ await factManager.save(dehydrateFact(author).map(f => ({ fact: f, signatures: [] })));
85
+
86
+ factManager.removeSpecificationListener(listener);
87
+ expect(fired).toBe(false);
88
+ });
89
+
90
+ it("does not fire for facts of the right type but a different hash", async () => {
91
+ const { factManager } = makeFactManager();
92
+
93
+ const expected = new User("expected-key");
94
+ const expectedRef = dehydrateFact(expected)[0];
95
+ const other = new User("other-key");
96
+
97
+ let matched = false;
98
+ const anchorSpec: Specification = {
99
+ given: [{ label: { name: "x", type: expectedRef.type }, conditions: [] }],
100
+ matches: [],
101
+ projection: { type: "fact", label: "x" }
102
+ };
103
+ const listener = factManager.addSpecificationListener(anchorSpec, async (results) => {
104
+ for (const r of results) {
105
+ const ref = (r.tuple as any).x;
106
+ if (ref && ref.type === expectedRef.type && ref.hash === expectedRef.hash) {
107
+ matched = true;
108
+ }
109
+ }
110
+ });
111
+
112
+ await factManager.save(dehydrateFact(other).map(f => ({ fact: f, signatures: [] })));
113
+
114
+ factManager.removeSpecificationListener(listener);
115
+ expect(matched).toBe(false);
116
+ });
117
+ });
@@ -0,0 +1,245 @@
1
+ import {
2
+ AuthorizationRules,
3
+ buildModel,
4
+ dehydrateFact,
5
+ DistributionRules,
6
+ FactManager,
7
+ FeedCache,
8
+ FeedResponse,
9
+ MemoryStore,
10
+ NetworkNoOp,
11
+ ObservableSource,
12
+ PassThroughFork,
13
+ User
14
+ } from "jinaga";
15
+
16
+ import { AuthorizationKeystore } from "../../src/authorization/authorization-keystore";
17
+ import { HttpRouter, RequestUser } from "../../src/http/router";
18
+ import { Stream } from "../../src/http/stream";
19
+ import { MemoryKeystore } from "../../src/memory/memory-keystore";
20
+
21
+ // Mirrors the jinaga.js #130 contract: a client who cannot yet read a
22
+ // feed must still be able to subscribe, receive an empty initial result,
23
+ // and start receiving rows the moment an authorizing fact arrives — all
24
+ // without the connection dying on a Forbidden.
25
+
26
+ class Office {
27
+ public static Type = "late.Office" as const;
28
+ public type = Office.Type;
29
+ constructor(public company: Company, public name: string) { }
30
+ }
31
+
32
+ class Company {
33
+ public static Type = "late.Company" as const;
34
+ public type = Company.Type;
35
+ constructor(public creator: User, public identifier: string) { }
36
+ }
37
+
38
+ class Administrator {
39
+ public static Type = "late.Administrator" as const;
40
+ public type = Administrator.Type;
41
+ constructor(public company: Company, public user: User, public date: Date | string) { }
42
+ }
43
+
44
+ const model = buildModel(b => b
45
+ .type(User)
46
+ .type(Company, m => m.predecessor("creator", User))
47
+ .type(Office, m => m.predecessor("company", Company))
48
+ .type(Administrator, m => m
49
+ .predecessor("company", Company)
50
+ .predecessor("user", User))
51
+ );
52
+
53
+ const subscriberIdentity = { provider: "mock", id: "subscriber" };
54
+ const creatorIdentity = { provider: "mock", id: "creator" };
55
+
56
+ async function makeHarness() {
57
+ const storage = new MemoryStore();
58
+ const keystore = new MemoryKeystore();
59
+ const subscriberFact = await keystore.getOrCreateUserFact(subscriberIdentity);
60
+ const creatorFact = await keystore.getOrCreateUserFact(creatorIdentity);
61
+ const subscriber = new User(subscriberFact.fields.publicKey);
62
+ const creator = new User(creatorFact.fields.publicKey);
63
+
64
+ const fork = new PassThroughFork(storage);
65
+ const observable = new ObservableSource(storage);
66
+ const network = new NetworkNoOp();
67
+ const factManager = new FactManager(fork, observable, storage, network, []);
68
+
69
+ const authorizationRules = new AuthorizationRules(model)
70
+ .any(User)
71
+ .any(Company)
72
+ .any(Office)
73
+ .any(Administrator);
74
+ const distributionRules = new DistributionRules([])
75
+ .share(model.given(Company).match((c, facts) =>
76
+ facts.ofType(Office).join(o => o.company, c)
77
+ ))
78
+ .with(model.given(Company).match((c, facts) =>
79
+ facts.ofType(Administrator)
80
+ .join(a => a.company, c)
81
+ .selectMany(a => facts.ofType(User).join(u => u, a.user))
82
+ ));
83
+ const authorization = new AuthorizationKeystore(
84
+ factManager, storage, keystore, authorizationRules, distributionRules);
85
+ const feedCache = new FeedCache();
86
+ const router = new HttpRouter(factManager, authorization, feedCache, "*");
87
+
88
+ const requestUser: RequestUser = {
89
+ provider: subscriberIdentity.provider,
90
+ id: subscriberIdentity.id,
91
+ profile: {} as any
92
+ };
93
+ return { router, factManager, subscriber, creator, storage, requestUser };
94
+ }
95
+
96
+ async function drainMicrotasks() {
97
+ for (let i = 0; i < 8; i++) {
98
+ await new Promise<void>(resolve => setImmediate(resolve));
99
+ }
100
+ }
101
+
102
+ describe("late-auth subscription recovery", () => {
103
+ it("subscribes empty when not authorized and activates when the auth fact arrives", async () => {
104
+ const h = await makeHarness();
105
+ const company = new Company(h.creator, "Acme");
106
+ const companyRef = dehydrateFact(company).find(f => f.type === Company.Type)!;
107
+ // Seed the Company and an existing Office. The subscriber is not
108
+ // yet an Administrator, so the rule denies them today.
109
+ const existingOffice = new Office(company, "HQ");
110
+ await h.factManager.save(dehydrateFact(existingOffice).map(f => ({ fact: f, signatures: [] })));
111
+
112
+ // Use the same given label name the rule's specs produce
113
+ // (`p1`, from `model.given(Company)`), so intersection can lift
114
+ // the rule's path conditions onto the subscription's given.
115
+ const input =
116
+ `let p1: ${Company.Type} = #${companyRef.hash}\n` +
117
+ `(p1: ${Company.Type}) {\n` +
118
+ ` o: ${Office.Type} [\n` +
119
+ ` o->company: ${Company.Type} = p1\n` +
120
+ ` ]\n` +
121
+ `} => o`;
122
+
123
+ // POST /feeds must NOT throw Forbidden — the client is subscribing.
124
+ const feedsResponse = await (h.router as any).feeds(h.requestUser, input);
125
+ expect(feedsResponse.feeds.length).toBeGreaterThan(0);
126
+ const feedHash: string = feedsResponse.feeds[0];
127
+
128
+ const stream: Stream<FeedResponse> = await (h.router as any).streamFeed(
129
+ h.requestUser, { hash: feedHash }, {});
130
+ expect(stream).not.toBeNull();
131
+
132
+ const received: FeedResponse[] = [];
133
+ stream.next(r => { received.push(r); });
134
+ await drainMicrotasks();
135
+
136
+ // Initial query is empty even though Office exists — the lifted
137
+ // auth condition (Administrator → subscriber) is not satisfied.
138
+ const initialOffices = received.flatMap(r => r.references)
139
+ .filter(r => r.type === Office.Type);
140
+ expect(initialOffices).toHaveLength(0);
141
+
142
+ // The authorizing fact arrives: subscriber is granted Admin.
143
+ const admin = new Administrator(company, h.subscriber, new Date("2026-05-27"));
144
+ await h.factManager.save(dehydrateFact(admin).map(f => ({ fact: f, signatures: [] })));
145
+ await drainMicrotasks();
146
+
147
+ stream.close();
148
+
149
+ // The pre-existing office surfaces as soon as the auth fact lands.
150
+ const officesAfterAuth = received.flatMap(r => r.references)
151
+ .filter(r => r.type === Office.Type);
152
+ expect(officesAfterAuth.map(r => r.hash))
153
+ .toContain(dehydrateFact(existingOffice).find(f => f.type === Office.Type)!.hash);
154
+ });
155
+
156
+ it("does not let another authenticated user reuse an intersected feed hash", async () => {
157
+ const h = await makeHarness();
158
+ const company = new Company(h.creator, "Acme");
159
+ const companyRef = dehydrateFact(company).find(f => f.type === Company.Type)!;
160
+
161
+ // Alice (the subscriber) gets an authorizing Administrator and an
162
+ // existing Office. Without intersection she would already be
163
+ // authorized — but we want the *cached* intersected hash, so we
164
+ // build it before saving the Administrator.
165
+ const office = new Office(company, "HQ");
166
+ await h.factManager.save(dehydrateFact(office).map(f => ({ fact: f, signatures: [] })));
167
+
168
+ const input =
169
+ `let p1: ${Company.Type} = #${companyRef.hash}\n` +
170
+ `(p1: ${Company.Type}) {\n` +
171
+ ` o: ${Office.Type} [\n` +
172
+ ` o->company: ${Company.Type} = p1\n` +
173
+ ` ]\n` +
174
+ `} => o`;
175
+
176
+ const aliceResponse = await (h.router as any).feeds(h.requestUser, input);
177
+ const intersectedHash: string = aliceResponse.feeds[0];
178
+
179
+ // Now grant Alice the Administrator role so the lifted spec
180
+ // actually produces rows when queried as Alice.
181
+ const admin = new Administrator(company, h.subscriber, new Date("2026-05-27"));
182
+ await h.factManager.save(dehydrateFact(admin).map(f => ({ fact: f, signatures: [] })));
183
+
184
+ // Alice can read her own intersected feed and sees the office.
185
+ const alicePage = await (h.router as any).feed(h.requestUser, { hash: intersectedHash }, {});
186
+ expect(alicePage.references.some((r: any) => r.type === Office.Type)).toBe(true);
187
+
188
+ // Mallory authenticates as a different user, then attempts to
189
+ // reuse the hash she obtained out of band. She must not be able
190
+ // to read Alice's data: the cached spec carries Alice's user
191
+ // fact, so allowing feedPreVerified would leak across users.
192
+ const malloryIdentity = { provider: "mock", id: "mallory" };
193
+ // Register Mallory with the keystore so getUserFact succeeds.
194
+ const malloryKeystore = (h.factManager as any); // unused; just to satisfy types
195
+ void malloryKeystore;
196
+ // The harness keystore is private; the lookup happens via the
197
+ // authorization keystore which the router holds. Re-using the
198
+ // harness keystore: get a fact for Mallory directly.
199
+ // (We rely on the keystore being shared with authorization.)
200
+ const malloryRequest: RequestUser = {
201
+ provider: malloryIdentity.provider,
202
+ id: malloryIdentity.id,
203
+ profile: {} as any
204
+ };
205
+
206
+ // First make Mallory exist in the keystore via a login-style call.
207
+ await (h.router as any).login(malloryRequest);
208
+
209
+ const malloryPage = await (h.router as any).feed(malloryRequest, { hash: intersectedHash }, {});
210
+ // The router must NOT have served Alice's data to Mallory. With
211
+ // the bind-to-owner fix in place, queryFeed routes Mallory
212
+ // through the normal distribution check, which fails, and the
213
+ // polling path returns an empty page.
214
+ expect(malloryPage.references.filter((r: any) => r.type === Office.Type)).toHaveLength(0);
215
+ });
216
+
217
+ it("does not return a 403 from /feeds when no rule applies — connection stays available", async () => {
218
+ const h = await makeHarness();
219
+
220
+ // A spec the distribution rules do not cover at all. Step 1's
221
+ // strict semantics would have thrown Forbidden here, but the
222
+ // subscribe contract is to keep the connection live so the
223
+ // client can wait for results (e.g., a future rule change).
224
+ const company = new Company(h.creator, "Acme");
225
+ const companyRef = dehydrateFact(company).find(f => f.type === Company.Type)!;
226
+ await h.factManager.save(dehydrateFact(company).map(f => ({ fact: f, signatures: [] })));
227
+
228
+ const input =
229
+ `let p1: ${Company.Type} = #${companyRef.hash}\n` +
230
+ `(p1: ${Company.Type}) {\n` +
231
+ ` a: ${Administrator.Type} [\n` +
232
+ ` a->company: ${Company.Type} = p1\n` +
233
+ ` ]\n` +
234
+ `} => a`;
235
+
236
+ const feedsResponse = await (h.router as any).feeds(h.requestUser, input);
237
+ expect(feedsResponse.feeds.length).toBeGreaterThan(0);
238
+
239
+ // Polling the cached feed must also stay live (empty page, not 403).
240
+ const feedHash: string = feedsResponse.feeds[0];
241
+ const page = await (h.router as any).feed(h.requestUser, { hash: feedHash }, {});
242
+ expect(page).not.toBeNull();
243
+ expect(page.references).toEqual([]);
244
+ });
245
+ });