jinaga-server 3.5.5 → 3.7.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.
- package/dist/authorization/authorization-keystore.d.ts +34 -2
- package/dist/authorization/authorization-keystore.js +59 -4
- package/dist/authorization/authorization-keystore.js.map +1 -1
- package/dist/feeds/feed-stream-session.d.ts +84 -0
- package/dist/feeds/feed-stream-session.js +331 -0
- package/dist/feeds/feed-stream-session.js.map +1 -0
- package/dist/http/router.d.ts +10 -3
- package/dist/http/router.js +186 -184
- package/dist/http/router.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/jinaga-server.d.ts +7 -0
- package/dist/jinaga-server.js +19 -2
- package/dist/jinaga-server.js.map +1 -1
- package/dist/memory/memory-keystore.js +1 -1
- package/dist/memory/memory-keystore.js.map +1 -1
- package/dist/postgres/query-description.d.ts +1 -0
- package/dist/postgres/query-description.js +34 -3
- package/dist/postgres/query-description.js.map +1 -1
- package/dist/postgres/specification-sql.js +7 -1
- package/dist/postgres/specification-sql.js.map +1 -1
- package/docs/design/feeds-persistent-proxy-plan.md +11 -1
- package/integration-test/jinaga-test/distribution-multi-given.test.js +184 -0
- package/integration-test/jinaga-test/distribution-negating-feed.test.js +202 -0
- package/integration-test/jinaga-test/feed-existential-input.test.js +241 -0
- package/integration-test/jinaga-test/feeds-endpoint.test.js +140 -0
- package/integration-test/jinaga-test/package-lock.json +4 -4
- package/integration-test/jinaga-test/package.json +3 -2
- package/integration-test/jinaga-test/subscription-anchor-arrival.test.js +129 -0
- package/integration-test/jinaga-test/subscription-helpers.js +60 -0
- package/integration-test/jinaga-test/subscription-late-auth.test.js +245 -0
- package/integration-test/jinaga-test/subscription-no-rule.test.js +137 -0
- package/package.json +2 -2
- package/src/authorization/authorization-keystore.ts +106 -5
- package/src/feeds/feed-stream-session.ts +386 -0
- package/src/http/router.ts +212 -237
- package/src/index.ts +2 -1
- package/src/jinaga-server.ts +45 -5
- package/src/memory/memory-keystore.ts +1 -1
- package/src/postgres/query-description.ts +66 -11
- package/src/postgres/specification-sql.ts +7 -1
- package/test/authorization/intermediateProjectionFeedSpec.ts +189 -0
- package/test/authorization/subscriptionIntersectionSpec.ts +220 -0
- package/test/feeds/feedStreamSessionRegressionSpec.ts +227 -0
- package/test/feeds/feedStreamSessionSpec.ts +351 -0
- package/test/http/anchorListenerSpec.ts +117 -0
- package/test/http/feedStreamSessionIntegrationSpec.ts +202 -0
- package/test/http/lateAuthRecoverySpec.ts +435 -0
- package/test/http/streamFeedAnchorSpec.ts +171 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AuthorizationRules,
|
|
3
|
+
buildModel,
|
|
4
|
+
dehydrateFact,
|
|
5
|
+
FactManager,
|
|
6
|
+
FeedCache,
|
|
7
|
+
FeedResponse,
|
|
8
|
+
MemoryStore,
|
|
9
|
+
NetworkNoOp,
|
|
10
|
+
ObservableSource,
|
|
11
|
+
PassThroughFork,
|
|
12
|
+
User
|
|
13
|
+
} from "jinaga";
|
|
14
|
+
|
|
15
|
+
import { AuthorizationKeystore } from "../../src/authorization/authorization-keystore";
|
|
16
|
+
import { HttpRouter, RequestUser } from "../../src/http/router";
|
|
17
|
+
import { Stream } from "../../src/http/stream";
|
|
18
|
+
import { MemoryKeystore } from "../../src/memory/memory-keystore";
|
|
19
|
+
|
|
20
|
+
// End-to-end coverage of the FeedStreamSession wired through HttpRouter
|
|
21
|
+
// against a real in-memory FactManager. Exercises backfill, mid-backfill
|
|
22
|
+
// inserts, the SYNC frame, and post-SYNC real-time updates.
|
|
23
|
+
class Post {
|
|
24
|
+
public static Type = "stream.Post" as const;
|
|
25
|
+
public type = Post.Type;
|
|
26
|
+
constructor(public author: User, public body: string) { }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const model = buildModel(b => b
|
|
30
|
+
.type(User)
|
|
31
|
+
.type(Post, m => m.predecessor("author", User))
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const userIdentity = { provider: "mock", id: "subscriber" };
|
|
35
|
+
|
|
36
|
+
interface Harness {
|
|
37
|
+
router: HttpRouter;
|
|
38
|
+
factManager: FactManager;
|
|
39
|
+
storage: MemoryStore;
|
|
40
|
+
requestUser: RequestUser;
|
|
41
|
+
author: User;
|
|
42
|
+
authorRef: { type: string; hash: string };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function makeHarness(): Promise<Harness> {
|
|
46
|
+
const storage = new MemoryStore();
|
|
47
|
+
const keystore = new MemoryKeystore();
|
|
48
|
+
const userFact = await keystore.getOrCreateUserFact(userIdentity);
|
|
49
|
+
const author = new User(userFact.fields.publicKey);
|
|
50
|
+
const authorRef = dehydrateFact(author)[0];
|
|
51
|
+
|
|
52
|
+
const fork = new PassThroughFork(storage);
|
|
53
|
+
const observable = new ObservableSource(storage);
|
|
54
|
+
const network = new NetworkNoOp();
|
|
55
|
+
const factManager = new FactManager(fork, observable, storage, network, []);
|
|
56
|
+
|
|
57
|
+
const authorizationRules = new AuthorizationRules(model)
|
|
58
|
+
.any(User)
|
|
59
|
+
.any(Post);
|
|
60
|
+
const authorization = new AuthorizationKeystore(
|
|
61
|
+
factManager, storage, keystore, authorizationRules, null);
|
|
62
|
+
const feedCache = new FeedCache();
|
|
63
|
+
|
|
64
|
+
const router = new HttpRouter(factManager, authorization, feedCache, "*");
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
router,
|
|
68
|
+
factManager,
|
|
69
|
+
storage,
|
|
70
|
+
requestUser: { provider: userIdentity.provider, id: userIdentity.id, profile: {} as any },
|
|
71
|
+
author,
|
|
72
|
+
authorRef
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function waitForMicrotasks(): Promise<void> {
|
|
77
|
+
for (let i = 0; i < 10; i++) {
|
|
78
|
+
await new Promise<void>(resolve => setImmediate(resolve));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function feedInput(authorHash: string): string {
|
|
83
|
+
return (
|
|
84
|
+
`let p: Jinaga.User = #${authorHash}\n` +
|
|
85
|
+
`(p: Jinaga.User) {\n` +
|
|
86
|
+
` post: stream.Post [\n` +
|
|
87
|
+
` post->author: Jinaga.User = p\n` +
|
|
88
|
+
` ]\n` +
|
|
89
|
+
`} => post`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function savePost(h: Harness, body: string): Promise<void> {
|
|
94
|
+
const post = new Post(h.author, body);
|
|
95
|
+
const envelopes = dehydrateFact(post).map(f => ({ fact: f, signatures: [] }));
|
|
96
|
+
await h.factManager.save(envelopes);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
describe("HttpRouter streamFeed session integration", () => {
|
|
100
|
+
it("backfills all historical posts and ends with a SYNC frame", async () => {
|
|
101
|
+
const h = await makeHarness();
|
|
102
|
+
await h.factManager.save(dehydrateFact(h.author).map(f => ({ fact: f, signatures: [] })));
|
|
103
|
+
for (let i = 0; i < 5; i++) {
|
|
104
|
+
await savePost(h, `post-${i}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const feedsResponse = await (h.router as any).feeds(h.requestUser, feedInput(h.authorRef.hash));
|
|
108
|
+
const feedHash: string = feedsResponse.feeds[0];
|
|
109
|
+
|
|
110
|
+
const stream: Stream<FeedResponse> = await (h.router as any).streamFeed(
|
|
111
|
+
h.requestUser, { hash: feedHash }, {});
|
|
112
|
+
const received: FeedResponse[] = [];
|
|
113
|
+
stream.next(r => received.push(r));
|
|
114
|
+
|
|
115
|
+
await waitForMicrotasks();
|
|
116
|
+
stream.close();
|
|
117
|
+
|
|
118
|
+
const distinctPosts = new Set(received.flatMap(r => r.references)
|
|
119
|
+
.filter(r => r.type === Post.Type).map(r => r.hash));
|
|
120
|
+
expect(distinctPosts.size).toBe(5);
|
|
121
|
+
// The final frame is the SYNC signal: empty references.
|
|
122
|
+
expect(received[received.length - 1].references).toEqual([]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("does not lose posts inserted during backfill", async () => {
|
|
126
|
+
const h = await makeHarness();
|
|
127
|
+
await h.factManager.save(dehydrateFact(h.author).map(f => ({ fact: f, signatures: [] })));
|
|
128
|
+
await savePost(h, "historical");
|
|
129
|
+
|
|
130
|
+
const feedsResponse = await (h.router as any).feeds(h.requestUser, feedInput(h.authorRef.hash));
|
|
131
|
+
const feedHash: string = feedsResponse.feeds[0];
|
|
132
|
+
|
|
133
|
+
const stream: Stream<FeedResponse> = await (h.router as any).streamFeed(
|
|
134
|
+
h.requestUser, { hash: feedHash }, {});
|
|
135
|
+
const received: FeedResponse[] = [];
|
|
136
|
+
stream.next(r => received.push(r));
|
|
137
|
+
|
|
138
|
+
// Insert a post immediately, racing the backfill.
|
|
139
|
+
await savePost(h, "raced");
|
|
140
|
+
await waitForMicrotasks();
|
|
141
|
+
stream.close();
|
|
142
|
+
|
|
143
|
+
const distinctPosts = new Set(received.flatMap(r => r.references)
|
|
144
|
+
.filter(r => r.type === Post.Type).map(r => r.hash));
|
|
145
|
+
// Both the historical and the raced post are delivered.
|
|
146
|
+
expect(distinctPosts.size).toBe(2);
|
|
147
|
+
expect(received[received.length - 1].references).toEqual([]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("delivers post-SYNC updates in subsequent frames", async () => {
|
|
151
|
+
const h = await makeHarness();
|
|
152
|
+
await h.factManager.save(dehydrateFact(h.author).map(f => ({ fact: f, signatures: [] })));
|
|
153
|
+
|
|
154
|
+
const feedsResponse = await (h.router as any).feeds(h.requestUser, feedInput(h.authorRef.hash));
|
|
155
|
+
const feedHash: string = feedsResponse.feeds[0];
|
|
156
|
+
|
|
157
|
+
const stream: Stream<FeedResponse> = await (h.router as any).streamFeed(
|
|
158
|
+
h.requestUser, { hash: feedHash }, {});
|
|
159
|
+
const received: FeedResponse[] = [];
|
|
160
|
+
stream.next(r => received.push(r));
|
|
161
|
+
|
|
162
|
+
await waitForMicrotasks();
|
|
163
|
+
const framesBeforeUpdate = received.length;
|
|
164
|
+
// The first frame is the immediate SYNC (no history).
|
|
165
|
+
expect(received[framesBeforeUpdate - 1].references).toEqual([]);
|
|
166
|
+
|
|
167
|
+
// A new post after SYNC should arrive in a later frame.
|
|
168
|
+
await savePost(h, "after-sync");
|
|
169
|
+
await waitForMicrotasks();
|
|
170
|
+
stream.close();
|
|
171
|
+
|
|
172
|
+
const newFrames = received.slice(framesBeforeUpdate);
|
|
173
|
+
const distinctPosts = new Set(newFrames.flatMap(r => r.references)
|
|
174
|
+
.filter(r => r.type === Post.Type).map(r => r.hash));
|
|
175
|
+
expect(distinctPosts.size).toBe(1);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("cleans up listeners on disconnect", async () => {
|
|
179
|
+
const h = await makeHarness();
|
|
180
|
+
await h.factManager.save(dehydrateFact(h.author).map(f => ({ fact: f, signatures: [] })));
|
|
181
|
+
|
|
182
|
+
const removeSpy = jest.spyOn(h.factManager, "removeSpecificationListener");
|
|
183
|
+
|
|
184
|
+
const feedsResponse = await (h.router as any).feeds(h.requestUser, feedInput(h.authorRef.hash));
|
|
185
|
+
const feedHash: string = feedsResponse.feeds[0];
|
|
186
|
+
|
|
187
|
+
const stream: Stream<FeedResponse> = await (h.router as any).streamFeed(
|
|
188
|
+
h.requestUser, { hash: feedHash }, {});
|
|
189
|
+
stream.next(() => { });
|
|
190
|
+
await waitForMicrotasks();
|
|
191
|
+
|
|
192
|
+
const removedBefore = removeSpy.mock.calls.length;
|
|
193
|
+
stream.close();
|
|
194
|
+
|
|
195
|
+
// Closing the stream disposes the session, removing every listener.
|
|
196
|
+
expect(removeSpy.mock.calls.length).toBeGreaterThan(removedBefore);
|
|
197
|
+
|
|
198
|
+
// A post saved after disconnect must not reach the closed stream.
|
|
199
|
+
await savePost(h, "after-close");
|
|
200
|
+
await waitForMicrotasks();
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AuthorizationRules,
|
|
3
|
+
buildModel,
|
|
4
|
+
dehydrateFact,
|
|
5
|
+
DistributionRules,
|
|
6
|
+
FactManager,
|
|
7
|
+
FeedCache,
|
|
8
|
+
FeedResponse,
|
|
9
|
+
MemoryStore,
|
|
10
|
+
NetworkNoOp,
|
|
11
|
+
NoOpTracer,
|
|
12
|
+
ObservableSource,
|
|
13
|
+
PassThroughFork,
|
|
14
|
+
Trace,
|
|
15
|
+
Tracer,
|
|
16
|
+
User
|
|
17
|
+
} from "jinaga";
|
|
18
|
+
|
|
19
|
+
import { AuthorizationKeystore } from "../../src/authorization/authorization-keystore";
|
|
20
|
+
import { FeedNotFound, HttpRouter, RequestUser } from "../../src/http/router";
|
|
21
|
+
import { Stream } from "../../src/http/stream";
|
|
22
|
+
import { MemoryKeystore } from "../../src/memory/memory-keystore";
|
|
23
|
+
|
|
24
|
+
// Mirrors the jinaga.js #130 contract: a client who cannot yet read a
|
|
25
|
+
// feed must still be able to subscribe, receive an empty initial result,
|
|
26
|
+
// and start receiving rows the moment an authorizing fact arrives — all
|
|
27
|
+
// without the connection dying on a Forbidden.
|
|
28
|
+
|
|
29
|
+
class Office {
|
|
30
|
+
public static Type = "late.Office" as const;
|
|
31
|
+
public type = Office.Type;
|
|
32
|
+
constructor(public company: Company, public name: string) { }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
class Company {
|
|
36
|
+
public static Type = "late.Company" as const;
|
|
37
|
+
public type = Company.Type;
|
|
38
|
+
constructor(public creator: User, public identifier: string) { }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
class Administrator {
|
|
42
|
+
public static Type = "late.Administrator" as const;
|
|
43
|
+
public type = Administrator.Type;
|
|
44
|
+
constructor(public company: Company, public user: User, public date: Date | string) { }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const model = buildModel(b => b
|
|
48
|
+
.type(User)
|
|
49
|
+
.type(Company, m => m.predecessor("creator", User))
|
|
50
|
+
.type(Office, m => m.predecessor("company", Company))
|
|
51
|
+
.type(Administrator, m => m
|
|
52
|
+
.predecessor("company", Company)
|
|
53
|
+
.predecessor("user", User))
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const subscriberIdentity = { provider: "mock", id: "subscriber" };
|
|
57
|
+
const creatorIdentity = { provider: "mock", id: "creator" };
|
|
58
|
+
|
|
59
|
+
async function makeHarness() {
|
|
60
|
+
const storage = new MemoryStore();
|
|
61
|
+
const keystore = new MemoryKeystore();
|
|
62
|
+
const subscriberFact = await keystore.getOrCreateUserFact(subscriberIdentity);
|
|
63
|
+
const creatorFact = await keystore.getOrCreateUserFact(creatorIdentity);
|
|
64
|
+
const subscriber = new User(subscriberFact.fields.publicKey);
|
|
65
|
+
const creator = new User(creatorFact.fields.publicKey);
|
|
66
|
+
|
|
67
|
+
const fork = new PassThroughFork(storage);
|
|
68
|
+
const observable = new ObservableSource(storage);
|
|
69
|
+
const network = new NetworkNoOp();
|
|
70
|
+
const factManager = new FactManager(fork, observable, storage, network, []);
|
|
71
|
+
|
|
72
|
+
const authorizationRules = new AuthorizationRules(model)
|
|
73
|
+
.any(User)
|
|
74
|
+
.any(Company)
|
|
75
|
+
.any(Office)
|
|
76
|
+
.any(Administrator);
|
|
77
|
+
const distributionRules = new DistributionRules([])
|
|
78
|
+
.share(model.given(Company).match((c, facts) =>
|
|
79
|
+
facts.ofType(Office).join(o => o.company, c)
|
|
80
|
+
))
|
|
81
|
+
.with(model.given(Company).match((c, facts) =>
|
|
82
|
+
facts.ofType(Administrator)
|
|
83
|
+
.join(a => a.company, c)
|
|
84
|
+
.selectMany(a => facts.ofType(User).join(u => u, a.user))
|
|
85
|
+
));
|
|
86
|
+
const authorization = new AuthorizationKeystore(
|
|
87
|
+
factManager, storage, keystore, authorizationRules, distributionRules);
|
|
88
|
+
const feedCache = new FeedCache();
|
|
89
|
+
const router = new HttpRouter(factManager, authorization, feedCache, "*");
|
|
90
|
+
|
|
91
|
+
const requestUser: RequestUser = {
|
|
92
|
+
provider: subscriberIdentity.provider,
|
|
93
|
+
id: subscriberIdentity.id,
|
|
94
|
+
profile: {} as any
|
|
95
|
+
};
|
|
96
|
+
return { router, factManager, subscriber, creator, storage, requestUser };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function drainMicrotasks() {
|
|
100
|
+
for (let i = 0; i < 8; i++) {
|
|
101
|
+
await new Promise<void>(resolve => setImmediate(resolve));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Records Trace.metric calls so tests can assert the structured distribution
|
|
106
|
+
// signal (issue #168 S3) without grepping logs. Everything else is a no-op,
|
|
107
|
+
// and dependency() still runs its operation so feeds() works unchanged.
|
|
108
|
+
class CapturingTracer extends NoOpTracer implements Tracer {
|
|
109
|
+
public readonly metrics: { message: string; measurements: { [key: string]: number } }[] = [];
|
|
110
|
+
metric(message: string, measurements: { [key: string]: number }): void {
|
|
111
|
+
this.metrics.push({ message, measurements });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function withTracer<T>(tracer: Tracer, action: () => Promise<T>): Promise<T> {
|
|
116
|
+
Trace.configure(tracer);
|
|
117
|
+
try {
|
|
118
|
+
return await action();
|
|
119
|
+
} finally {
|
|
120
|
+
Trace.configure(new NoOpTracer());
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
describe("late-auth subscription recovery", () => {
|
|
125
|
+
it("subscribes empty when not authorized and activates when the auth fact arrives", async () => {
|
|
126
|
+
const h = await makeHarness();
|
|
127
|
+
const company = new Company(h.creator, "Acme");
|
|
128
|
+
const companyRef = dehydrateFact(company).find(f => f.type === Company.Type)!;
|
|
129
|
+
// Seed the Company and an existing Office. The subscriber is not
|
|
130
|
+
// yet an Administrator, so the rule denies them today.
|
|
131
|
+
const existingOffice = new Office(company, "HQ");
|
|
132
|
+
await h.factManager.save(dehydrateFact(existingOffice).map(f => ({ fact: f, signatures: [] })));
|
|
133
|
+
|
|
134
|
+
// Use the same given label name the rule's specs produce
|
|
135
|
+
// (`p1`, from `model.given(Company)`), so intersection can lift
|
|
136
|
+
// the rule's path conditions onto the subscription's given.
|
|
137
|
+
const input =
|
|
138
|
+
`let p1: ${Company.Type} = #${companyRef.hash}\n` +
|
|
139
|
+
`(p1: ${Company.Type}) {\n` +
|
|
140
|
+
` o: ${Office.Type} [\n` +
|
|
141
|
+
` o->company: ${Company.Type} = p1\n` +
|
|
142
|
+
` ]\n` +
|
|
143
|
+
`} => o`;
|
|
144
|
+
|
|
145
|
+
// POST /feeds must NOT throw Forbidden — the client is subscribing.
|
|
146
|
+
const feedsResponse = await (h.router as any).feeds(h.requestUser, input);
|
|
147
|
+
expect(feedsResponse.feeds.length).toBeGreaterThan(0);
|
|
148
|
+
const feedHash: string = feedsResponse.feeds[0];
|
|
149
|
+
|
|
150
|
+
// S1: the response reports a per-feed decision. This is the reactive
|
|
151
|
+
// (authorized-via-intersection) case — denied for the subscriber now,
|
|
152
|
+
// self-heals when the Administrator fact arrives — reported without
|
|
153
|
+
// any change to registration or keep-alive behavior.
|
|
154
|
+
expect(feedsResponse.decisions).toHaveLength(feedsResponse.feeds.length);
|
|
155
|
+
expect(feedsResponse.decisions.map((d: any) => d.feed).sort())
|
|
156
|
+
.toEqual([...feedsResponse.feeds].sort());
|
|
157
|
+
for (const d of feedsResponse.decisions) {
|
|
158
|
+
expect(d.decision).toBe("reactive");
|
|
159
|
+
expect(d.code).toBe("principal-excluded");
|
|
160
|
+
expect(d.reason).toBeTruthy();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const stream: Stream<FeedResponse> = await (h.router as any).streamFeed(
|
|
164
|
+
h.requestUser, { hash: feedHash }, {});
|
|
165
|
+
expect(stream).not.toBeNull();
|
|
166
|
+
|
|
167
|
+
const received: FeedResponse[] = [];
|
|
168
|
+
stream.next(r => { received.push(r); });
|
|
169
|
+
await drainMicrotasks();
|
|
170
|
+
|
|
171
|
+
// Initial query is empty even though Office exists — the lifted
|
|
172
|
+
// auth condition (Administrator → subscriber) is not satisfied.
|
|
173
|
+
const initialOffices = received.flatMap(r => r.references)
|
|
174
|
+
.filter(r => r.type === Office.Type);
|
|
175
|
+
expect(initialOffices).toHaveLength(0);
|
|
176
|
+
|
|
177
|
+
// The authorizing fact arrives: subscriber is granted Admin.
|
|
178
|
+
const admin = new Administrator(company, h.subscriber, new Date("2026-05-27"));
|
|
179
|
+
await h.factManager.save(dehydrateFact(admin).map(f => ({ fact: f, signatures: [] })));
|
|
180
|
+
await drainMicrotasks();
|
|
181
|
+
|
|
182
|
+
stream.close();
|
|
183
|
+
|
|
184
|
+
// The pre-existing office surfaces as soon as the auth fact lands.
|
|
185
|
+
const officesAfterAuth = received.flatMap(r => r.references)
|
|
186
|
+
.filter(r => r.type === Office.Type);
|
|
187
|
+
expect(officesAfterAuth.map(r => r.hash))
|
|
188
|
+
.toContain(dehydrateFact(existingOffice).find(f => f.type === Office.Type)!.hash);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("reports an authorized pass-through decision when the user is already permitted", async () => {
|
|
192
|
+
const h = await makeHarness();
|
|
193
|
+
const company = new Company(h.creator, "Acme");
|
|
194
|
+
const companyRef = dehydrateFact(company).find(f => f.type === Company.Type)!;
|
|
195
|
+
await h.factManager.save(dehydrateFact(company).map(f => ({ fact: f, signatures: [] })));
|
|
196
|
+
|
|
197
|
+
// Grant the subscriber the Administrator role *before* subscribing, so
|
|
198
|
+
// the distribution rule authorizes the spec as-is — pass-through, no
|
|
199
|
+
// intersection.
|
|
200
|
+
const admin = new Administrator(company, h.subscriber, new Date("2026-05-27"));
|
|
201
|
+
await h.factManager.save(dehydrateFact(admin).map(f => ({ fact: f, signatures: [] })));
|
|
202
|
+
|
|
203
|
+
const input =
|
|
204
|
+
`let p1: ${Company.Type} = #${companyRef.hash}\n` +
|
|
205
|
+
`(p1: ${Company.Type}) {\n` +
|
|
206
|
+
` o: ${Office.Type} [\n` +
|
|
207
|
+
` o->company: ${Company.Type} = p1\n` +
|
|
208
|
+
` ]\n` +
|
|
209
|
+
`} => o`;
|
|
210
|
+
|
|
211
|
+
const feedsResponse = await (h.router as any).feeds(h.requestUser, input);
|
|
212
|
+
expect(feedsResponse.feeds.length).toBeGreaterThan(0);
|
|
213
|
+
|
|
214
|
+
// S1: authorized decisions carry no denial code.
|
|
215
|
+
expect(feedsResponse.decisions).toHaveLength(feedsResponse.feeds.length);
|
|
216
|
+
for (const d of feedsResponse.decisions) {
|
|
217
|
+
expect(d.decision).toBe("authorized");
|
|
218
|
+
expect(d.code).toBeUndefined();
|
|
219
|
+
expect(d.reason).toBeTruthy();
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("does not let another authenticated user reuse an intersected feed hash", async () => {
|
|
224
|
+
const h = await makeHarness();
|
|
225
|
+
const company = new Company(h.creator, "Acme");
|
|
226
|
+
const companyRef = dehydrateFact(company).find(f => f.type === Company.Type)!;
|
|
227
|
+
|
|
228
|
+
// Alice (the subscriber) gets an authorizing Administrator and an
|
|
229
|
+
// existing Office. Without intersection she would already be
|
|
230
|
+
// authorized — but we want the *cached* intersected hash, so we
|
|
231
|
+
// build it before saving the Administrator.
|
|
232
|
+
const office = new Office(company, "HQ");
|
|
233
|
+
await h.factManager.save(dehydrateFact(office).map(f => ({ fact: f, signatures: [] })));
|
|
234
|
+
|
|
235
|
+
const input =
|
|
236
|
+
`let p1: ${Company.Type} = #${companyRef.hash}\n` +
|
|
237
|
+
`(p1: ${Company.Type}) {\n` +
|
|
238
|
+
` o: ${Office.Type} [\n` +
|
|
239
|
+
` o->company: ${Company.Type} = p1\n` +
|
|
240
|
+
` ]\n` +
|
|
241
|
+
`} => o`;
|
|
242
|
+
|
|
243
|
+
const aliceResponse = await (h.router as any).feeds(h.requestUser, input);
|
|
244
|
+
const intersectedHash: string = aliceResponse.feeds[0];
|
|
245
|
+
|
|
246
|
+
// Now grant Alice the Administrator role so the lifted spec
|
|
247
|
+
// actually produces rows when queried as Alice.
|
|
248
|
+
const admin = new Administrator(company, h.subscriber, new Date("2026-05-27"));
|
|
249
|
+
await h.factManager.save(dehydrateFact(admin).map(f => ({ fact: f, signatures: [] })));
|
|
250
|
+
|
|
251
|
+
// Alice can read her own intersected feed and sees the office.
|
|
252
|
+
const alicePage = await (h.router as any).feed(h.requestUser, { hash: intersectedHash }, {});
|
|
253
|
+
expect(alicePage.references.some((r: any) => r.type === Office.Type)).toBe(true);
|
|
254
|
+
|
|
255
|
+
// Mallory authenticates as a different user, then attempts to
|
|
256
|
+
// reuse the hash she obtained out of band. She must not be able
|
|
257
|
+
// to read Alice's data: the cached spec carries Alice's user
|
|
258
|
+
// fact, so allowing feedPreVerified would leak across users.
|
|
259
|
+
const malloryIdentity = { provider: "mock", id: "mallory" };
|
|
260
|
+
// Register Mallory with the keystore so getUserFact succeeds.
|
|
261
|
+
const malloryKeystore = (h.factManager as any); // unused; just to satisfy types
|
|
262
|
+
void malloryKeystore;
|
|
263
|
+
// The harness keystore is private; the lookup happens via the
|
|
264
|
+
// authorization keystore which the router holds. Re-using the
|
|
265
|
+
// harness keystore: get a fact for Mallory directly.
|
|
266
|
+
// (We rely on the keystore being shared with authorization.)
|
|
267
|
+
const malloryRequest: RequestUser = {
|
|
268
|
+
provider: malloryIdentity.provider,
|
|
269
|
+
id: malloryIdentity.id,
|
|
270
|
+
profile: {} as any
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
// First make Mallory exist in the keystore via a login-style call.
|
|
274
|
+
await (h.router as any).login(malloryRequest);
|
|
275
|
+
|
|
276
|
+
const malloryPage = await (h.router as any).feed(malloryRequest, { hash: intersectedHash }, {});
|
|
277
|
+
// The router must NOT have served Alice's data to Mallory. With
|
|
278
|
+
// the bind-to-owner fix in place, queryFeed routes Mallory
|
|
279
|
+
// through the normal distribution check, which fails, and the
|
|
280
|
+
// polling path returns an empty page.
|
|
281
|
+
expect(malloryPage.references.filter((r: any) => r.type === Office.Type)).toHaveLength(0);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("does not return a 403 from /feeds when no rule applies — connection stays available", async () => {
|
|
285
|
+
const h = await makeHarness();
|
|
286
|
+
|
|
287
|
+
// A spec the distribution rules do not cover at all. Step 1's
|
|
288
|
+
// strict semantics would have thrown Forbidden here, but the
|
|
289
|
+
// subscribe contract is to keep the connection live so the
|
|
290
|
+
// client can wait for results (e.g., a future rule change).
|
|
291
|
+
const company = new Company(h.creator, "Acme");
|
|
292
|
+
const companyRef = dehydrateFact(company).find(f => f.type === Company.Type)!;
|
|
293
|
+
await h.factManager.save(dehydrateFact(company).map(f => ({ fact: f, signatures: [] })));
|
|
294
|
+
|
|
295
|
+
const input =
|
|
296
|
+
`let p1: ${Company.Type} = #${companyRef.hash}\n` +
|
|
297
|
+
`(p1: ${Company.Type}) {\n` +
|
|
298
|
+
` a: ${Administrator.Type} [\n` +
|
|
299
|
+
` a->company: ${Company.Type} = p1\n` +
|
|
300
|
+
` ]\n` +
|
|
301
|
+
`} => a`;
|
|
302
|
+
|
|
303
|
+
const feedsResponse = await (h.router as any).feeds(h.requestUser, input);
|
|
304
|
+
expect(feedsResponse.feeds.length).toBeGreaterThan(0);
|
|
305
|
+
|
|
306
|
+
// S1: no rule covers this spec, so the decision is `denied` with the
|
|
307
|
+
// structured `no-matching-rule` code. The feed is still registered and
|
|
308
|
+
// served (empty), exactly as before — this only reports the decision.
|
|
309
|
+
expect(feedsResponse.decisions).toHaveLength(feedsResponse.feeds.length);
|
|
310
|
+
for (const d of feedsResponse.decisions) {
|
|
311
|
+
expect(d.decision).toBe("denied");
|
|
312
|
+
expect(d.code).toBe("no-matching-rule");
|
|
313
|
+
expect(d.reason).toBeTruthy();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Polling the cached feed must also stay live (empty page, not 403).
|
|
317
|
+
const feedHash: string = feedsResponse.feeds[0];
|
|
318
|
+
const page = await (h.router as any).feed(h.requestUser, { hash: feedHash }, {});
|
|
319
|
+
expect(page).not.toBeNull();
|
|
320
|
+
expect(page.references).toEqual([]);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("S2: recomputes the per-user decision on a repeated POST /feeds", async () => {
|
|
324
|
+
const h = await makeHarness();
|
|
325
|
+
const company = new Company(h.creator, "Acme");
|
|
326
|
+
const companyRef = dehydrateFact(company).find(f => f.type === Company.Type)!;
|
|
327
|
+
const office = new Office(company, "HQ");
|
|
328
|
+
await h.factManager.save(dehydrateFact(office).map(f => ({ fact: f, signatures: [] })));
|
|
329
|
+
|
|
330
|
+
const input =
|
|
331
|
+
`let p1: ${Company.Type} = #${companyRef.hash}\n` +
|
|
332
|
+
`(p1: ${Company.Type}) {\n` +
|
|
333
|
+
` o: ${Office.Type} [\n` +
|
|
334
|
+
` o->company: ${Company.Type} = p1\n` +
|
|
335
|
+
` ]\n` +
|
|
336
|
+
`} => o`;
|
|
337
|
+
|
|
338
|
+
// First registration: subscriber is not yet an Administrator, so the
|
|
339
|
+
// decision is reactive (authorized via intersection).
|
|
340
|
+
const first = await (h.router as any).feeds(h.requestUser, input);
|
|
341
|
+
expect(first.decisions.every((d: any) => d.decision === "reactive")).toBe(true);
|
|
342
|
+
|
|
343
|
+
// The authorizing fact arrives.
|
|
344
|
+
const admin = new Administrator(company, h.subscriber, new Date("2026-05-27"));
|
|
345
|
+
await h.factManager.save(dehydrateFact(admin).map(f => ({ fact: f, signatures: [] })));
|
|
346
|
+
|
|
347
|
+
// A repeated POST /feeds for the same spec now returns `authorized` —
|
|
348
|
+
// the decision self-healed rather than returning a stale cached value.
|
|
349
|
+
const second = await (h.router as any).feeds(h.requestUser, input);
|
|
350
|
+
expect(second.decisions.length).toBeGreaterThan(0);
|
|
351
|
+
for (const d of second.decisions) {
|
|
352
|
+
expect(d.decision).toBe("authorized");
|
|
353
|
+
expect(d.code).toBeUndefined();
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("S2: returns different per-user decisions for the same spec", async () => {
|
|
358
|
+
const h = await makeHarness();
|
|
359
|
+
const company = new Company(h.creator, "Acme");
|
|
360
|
+
const companyRef = dehydrateFact(company).find(f => f.type === Company.Type)!;
|
|
361
|
+
await h.factManager.save(dehydrateFact(company).map(f => ({ fact: f, signatures: [] })));
|
|
362
|
+
|
|
363
|
+
// Authorize the subscriber but not Mallory.
|
|
364
|
+
const admin = new Administrator(company, h.subscriber, new Date("2026-05-27"));
|
|
365
|
+
await h.factManager.save(dehydrateFact(admin).map(f => ({ fact: f, signatures: [] })));
|
|
366
|
+
|
|
367
|
+
const input =
|
|
368
|
+
`let p1: ${Company.Type} = #${companyRef.hash}\n` +
|
|
369
|
+
`(p1: ${Company.Type}) {\n` +
|
|
370
|
+
` o: ${Office.Type} [\n` +
|
|
371
|
+
` o->company: ${Company.Type} = p1\n` +
|
|
372
|
+
` ]\n` +
|
|
373
|
+
`} => o`;
|
|
374
|
+
|
|
375
|
+
const subscriberResponse = await (h.router as any).feeds(h.requestUser, input);
|
|
376
|
+
expect(subscriberResponse.decisions.every((d: any) => d.decision === "authorized")).toBe(true);
|
|
377
|
+
|
|
378
|
+
// Mallory is a different authenticated user with no Administrator fact.
|
|
379
|
+
const malloryRequest: RequestUser = {
|
|
380
|
+
provider: "mock", id: "mallory", profile: {} as any
|
|
381
|
+
};
|
|
382
|
+
await (h.router as any).login(malloryRequest);
|
|
383
|
+
const malloryResponse = await (h.router as any).feeds(malloryRequest, input);
|
|
384
|
+
|
|
385
|
+
// Same spec, different user: the decision is computed for Mallory, not
|
|
386
|
+
// reused from the subscriber. Mallory is not authorized, so the spec is
|
|
387
|
+
// rewritten via intersection into a reactive, user-specific feed.
|
|
388
|
+
expect(malloryResponse.decisions.every((d: any) => d.decision === "reactive")).toBe(true);
|
|
389
|
+
// The reactive rewrite yields a distinct, owner-bound hash — never the
|
|
390
|
+
// subscriber's authorized pass-through hash.
|
|
391
|
+
expect(malloryResponse.feeds).not.toEqual(subscriberResponse.feeds);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("S3: emits a structured distribution.unmatched metric tagged by code on denial", async () => {
|
|
395
|
+
const h = await makeHarness();
|
|
396
|
+
const company = new Company(h.creator, "Acme");
|
|
397
|
+
const companyRef = dehydrateFact(company).find(f => f.type === Company.Type)!;
|
|
398
|
+
await h.factManager.save(dehydrateFact(company).map(f => ({ fact: f, signatures: [] })));
|
|
399
|
+
|
|
400
|
+
// A spec no distribution rule covers → denied · no-matching-rule.
|
|
401
|
+
const input =
|
|
402
|
+
`let p1: ${Company.Type} = #${companyRef.hash}\n` +
|
|
403
|
+
`(p1: ${Company.Type}) {\n` +
|
|
404
|
+
` a: ${Administrator.Type} [\n` +
|
|
405
|
+
` a->company: ${Company.Type} = p1\n` +
|
|
406
|
+
` ]\n` +
|
|
407
|
+
`} => a`;
|
|
408
|
+
|
|
409
|
+
const tracer = new CapturingTracer();
|
|
410
|
+
await withTracer(tracer, () => (h.router as any).feeds(h.requestUser, input));
|
|
411
|
+
|
|
412
|
+
const unmatched = tracer.metrics.filter(m => m.message === "distribution.unmatched");
|
|
413
|
+
expect(unmatched).toHaveLength(1);
|
|
414
|
+
// Stable metric name, denial code as the (bounded) measurement key.
|
|
415
|
+
expect(unmatched[0].measurements).toEqual({ "no-matching-rule": 1 });
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("S4: throws FeedNotFound for an unknown feed hash, but not for a missing hash", async () => {
|
|
419
|
+
const h = await makeHarness();
|
|
420
|
+
|
|
421
|
+
// A known route with an unknown/expired hash is a distinct condition
|
|
422
|
+
// from a route miss: the method throws FeedNotFound (handleError maps
|
|
423
|
+
// it to a 404 with a `feed_not_found` body), rather than returning the
|
|
424
|
+
// generic null → "Not Found".
|
|
425
|
+
await expect((h.router as any).feed(h.requestUser, { hash: "deadbeef" }, {}))
|
|
426
|
+
.rejects.toBeInstanceOf(FeedNotFound);
|
|
427
|
+
await expect((h.router as any).streamFeed(h.requestUser, { hash: "deadbeef" }, {}))
|
|
428
|
+
.rejects.toBeInstanceOf(FeedNotFound);
|
|
429
|
+
|
|
430
|
+
// A missing hash param (wrong URL) still collapses to the generic 404
|
|
431
|
+
// path — the method returns null, unchanged.
|
|
432
|
+
const missing = await (h.router as any).feed(h.requestUser, {}, {});
|
|
433
|
+
expect(missing).toBeNull();
|
|
434
|
+
});
|
|
435
|
+
});
|