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.
- package/.github/workflows/publish.yml +5 -2
- package/.github/workflows/upgrade-jinaga.yml +51 -0
- package/CLAUDE.md +77 -0
- package/dist/authorization/authorization-keystore.d.ts +27 -1
- package/dist/authorization/authorization-keystore.js +52 -4
- package/dist/authorization/authorization-keystore.js.map +1 -1
- package/dist/http/router.d.ts +4 -1
- package/dist/http/router.js +157 -14
- package/dist/http/router.js.map +1 -1
- package/dist/jinaga-server.js +18 -1
- 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/analysis/feed-builder-nested-existential-bug.md +191 -0
- 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 +87 -5
- package/src/http/router.ts +174 -21
- package/src/jinaga-server.ts +36 -3
- 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/subscriptionIntersectionSpec.ts +208 -0
- package/test/http/anchorListenerSpec.ts +117 -0
- package/test/http/lateAuthRecoverySpec.ts +245 -0
- 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
|
+
});
|