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,245 @@
|
|
|
1
|
+
const request = require('supertest');
|
|
2
|
+
const { buildModel, User } = require('jinaga');
|
|
3
|
+
const { createSubscriptionApp, asUser, randomSuffix } = require('./subscription-helpers');
|
|
4
|
+
|
|
5
|
+
class Company {
|
|
6
|
+
static Type = "LateAuth.Company";
|
|
7
|
+
type = Company.Type;
|
|
8
|
+
constructor(creator, identifier) {
|
|
9
|
+
this.creator = creator;
|
|
10
|
+
this.identifier = identifier;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
class Office {
|
|
15
|
+
static Type = "LateAuth.Office";
|
|
16
|
+
type = Office.Type;
|
|
17
|
+
constructor(company, name) {
|
|
18
|
+
this.company = company;
|
|
19
|
+
this.name = name;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
class Administrator {
|
|
24
|
+
static Type = "LateAuth.Administrator";
|
|
25
|
+
type = Administrator.Type;
|
|
26
|
+
constructor(company, user, createdAt) {
|
|
27
|
+
this.company = company;
|
|
28
|
+
this.user = user;
|
|
29
|
+
this.createdAt = createdAt;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const model = buildModel(b => b
|
|
34
|
+
.type(User)
|
|
35
|
+
.type(Company, m => m.predecessor("creator", User))
|
|
36
|
+
.type(Office, m => m.predecessor("company", Company))
|
|
37
|
+
.type(Administrator, m => m
|
|
38
|
+
.predecessor("company", Company)
|
|
39
|
+
.predecessor("user", User))
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
function authorization(a) {
|
|
43
|
+
return a
|
|
44
|
+
.any(User)
|
|
45
|
+
.any(Company)
|
|
46
|
+
.any(Office)
|
|
47
|
+
.any(Administrator);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function distribution(d) {
|
|
51
|
+
return d
|
|
52
|
+
.share(model.given(Company).match((c, facts) =>
|
|
53
|
+
facts.ofType(Office).join(o => o.company, c)
|
|
54
|
+
))
|
|
55
|
+
.with(model.given(Company).match((c, facts) =>
|
|
56
|
+
facts.ofType(Administrator)
|
|
57
|
+
.join(a => a.company, c)
|
|
58
|
+
.selectMany(a => facts.ofType(User).join(u => u, a.user))
|
|
59
|
+
));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function officeSpecText(companyHash) {
|
|
63
|
+
// The `p1` label name mirrors what `model.given(Company)` produces
|
|
64
|
+
// so the distribution engine can lift the rule's path conditions onto
|
|
65
|
+
// the subscription's given during intersectForSubscribe.
|
|
66
|
+
return `let p1: ${Company.Type} = #${companyHash}\n` +
|
|
67
|
+
`(p1: ${Company.Type}) {\n` +
|
|
68
|
+
` o: ${Office.Type} [\n` +
|
|
69
|
+
` o->company: ${Company.Type} = p1\n` +
|
|
70
|
+
` ]\n` +
|
|
71
|
+
`} => o`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
jest.setTimeout(30000);
|
|
75
|
+
|
|
76
|
+
describe('Subscription late-auth recovery', () => {
|
|
77
|
+
let app, withSession, close;
|
|
78
|
+
|
|
79
|
+
beforeEach(async () => {
|
|
80
|
+
({ app, withSession, close } = await createSubscriptionApp({
|
|
81
|
+
model, authorization, distribution
|
|
82
|
+
}));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
afterEach(async () => {
|
|
86
|
+
if (close) await close();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('accepts the subscription with an empty page when the subscriber is not yet authorized', async () => {
|
|
90
|
+
const { companyHash } = await asUser(withSession, 'creator-1-' + randomSuffix(), async (creatorJ) => {
|
|
91
|
+
const { userFact: creatorFact } = await creatorJ.login();
|
|
92
|
+
const company = await creatorJ.fact(new Company(creatorFact, 'Acme-' + randomSuffix()));
|
|
93
|
+
await creatorJ.fact(new Office(company, 'HQ'));
|
|
94
|
+
return { companyHash: creatorJ.hash(company) };
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const subscriberId = 'subscriber-1-' + randomSuffix();
|
|
98
|
+
await asUser(withSession, subscriberId, async (j) => { await j.login(); });
|
|
99
|
+
|
|
100
|
+
// POST /feeds must NOT 403: distribution rule denies the subscriber,
|
|
101
|
+
// but the server intersects the spec so the subscription can be
|
|
102
|
+
// activated once the authorizing fact arrives.
|
|
103
|
+
const feedsResponse = await request(app)
|
|
104
|
+
.post('/feeds')
|
|
105
|
+
.set('Content-Type', 'text/plain')
|
|
106
|
+
.set('x-test-user-id', subscriberId)
|
|
107
|
+
.send(officeSpecText(companyHash));
|
|
108
|
+
|
|
109
|
+
expect(feedsResponse.status).toBe(200);
|
|
110
|
+
const { feeds } = JSON.parse(feedsResponse.text);
|
|
111
|
+
expect(Array.isArray(feeds)).toBe(true);
|
|
112
|
+
expect(feeds.length).toBeGreaterThan(0);
|
|
113
|
+
|
|
114
|
+
// Polling the cached hash returns an empty page — the pre-existing
|
|
115
|
+
// Office is hidden until the lifted auth condition is satisfied.
|
|
116
|
+
const pollResponse = await request(app)
|
|
117
|
+
.get(`/feeds/${feeds[0]}`)
|
|
118
|
+
.set('x-test-user-id', subscriberId);
|
|
119
|
+
|
|
120
|
+
expect(pollResponse.status).toBe(200);
|
|
121
|
+
const body = JSON.parse(pollResponse.text);
|
|
122
|
+
const officeRefs = (body.references || []).filter(r => r.type === Office.Type);
|
|
123
|
+
expect(officeRefs).toHaveLength(0);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('surfaces existing offices once the authorizing fact arrives', async () => {
|
|
127
|
+
let companyValue;
|
|
128
|
+
let creatorFactValue;
|
|
129
|
+
const creatorId = 'creator-2-' + randomSuffix();
|
|
130
|
+
const { companyHash, officeHash } = await asUser(withSession, creatorId, async (creatorJ) => {
|
|
131
|
+
const { userFact: creatorFact } = await creatorJ.login();
|
|
132
|
+
creatorFactValue = creatorFact;
|
|
133
|
+
const company = await creatorJ.fact(new Company(creatorFact, 'Acme-' + randomSuffix()));
|
|
134
|
+
companyValue = company;
|
|
135
|
+
const office = await creatorJ.fact(new Office(company, 'HQ'));
|
|
136
|
+
return {
|
|
137
|
+
companyHash: creatorJ.hash(company),
|
|
138
|
+
officeHash: creatorJ.hash(office)
|
|
139
|
+
};
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const subscriberId = 'subscriber-2-' + randomSuffix();
|
|
143
|
+
const subscriberFact = await asUser(withSession, subscriberId, async (j) => {
|
|
144
|
+
const { userFact } = await j.login();
|
|
145
|
+
return userFact;
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Subscribe BEFORE the authorizing fact exists so the cached hash is
|
|
149
|
+
// an intersected (lifted) spec — feedPreVerified will then serve it.
|
|
150
|
+
const feedsResponse = await request(app)
|
|
151
|
+
.post('/feeds')
|
|
152
|
+
.set('Content-Type', 'text/plain')
|
|
153
|
+
.set('x-test-user-id', subscriberId)
|
|
154
|
+
.send(officeSpecText(companyHash));
|
|
155
|
+
expect(feedsResponse.status).toBe(200);
|
|
156
|
+
const { feeds } = JSON.parse(feedsResponse.text);
|
|
157
|
+
const feedHash = feeds[0];
|
|
158
|
+
|
|
159
|
+
// Initial poll: empty (lifted condition not yet satisfied).
|
|
160
|
+
const firstPoll = await request(app)
|
|
161
|
+
.get(`/feeds/${feedHash}`)
|
|
162
|
+
.set('x-test-user-id', subscriberId);
|
|
163
|
+
expect(firstPoll.status).toBe(200);
|
|
164
|
+
const firstBody = JSON.parse(firstPoll.text);
|
|
165
|
+
expect((firstBody.references || []).filter(r => r.type === Office.Type)).toHaveLength(0);
|
|
166
|
+
|
|
167
|
+
// Creator grants the subscriber the Administrator role.
|
|
168
|
+
await asUser(withSession, creatorId, async (creatorJ) => {
|
|
169
|
+
await creatorJ.fact(new Administrator(companyValue, subscriberFact, new Date('2026-05-27')));
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Re-poll without propagating the prior bookmark. The intersected
|
|
173
|
+
// spec is 2-given, and the bookmark-extended SQL path currently
|
|
174
|
+
// generates a WHERE constraint on the second given without a
|
|
175
|
+
// corresponding JOIN (a separate PostgresStore bug). Re-querying
|
|
176
|
+
// from the beginning sidesteps that and still validates the
|
|
177
|
+
// contract — the office surfaces once the lifted condition holds.
|
|
178
|
+
const secondPoll = await request(app)
|
|
179
|
+
.get(`/feeds/${feedHash}`)
|
|
180
|
+
.set('x-test-user-id', subscriberId);
|
|
181
|
+
expect(secondPoll.status).toBe(200);
|
|
182
|
+
const secondBody = JSON.parse(secondPoll.text);
|
|
183
|
+
const officeRefs = (secondBody.references || []).filter(r => r.type === Office.Type);
|
|
184
|
+
expect(officeRefs.map(r => r.hash)).toContain(officeHash);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('does not let a different authenticated user reuse an intersected feed hash', async () => {
|
|
188
|
+
let companyValue;
|
|
189
|
+
const creatorId = 'creator-3-' + randomSuffix();
|
|
190
|
+
const { companyHash } = await asUser(withSession, creatorId, async (creatorJ) => {
|
|
191
|
+
const { userFact: creatorFact } = await creatorJ.login();
|
|
192
|
+
const company = await creatorJ.fact(new Company(creatorFact, 'Acme-' + randomSuffix()));
|
|
193
|
+
companyValue = company;
|
|
194
|
+
await creatorJ.fact(new Office(company, 'HQ'));
|
|
195
|
+
return { companyHash: creatorJ.hash(company) };
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const aliceId = 'alice-3-' + randomSuffix();
|
|
199
|
+
const aliceFact = await asUser(withSession, aliceId, async (j) => {
|
|
200
|
+
const { userFact } = await j.login();
|
|
201
|
+
return userFact;
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// Mallory must exist in the keystore for the polling auth path to
|
|
205
|
+
// resolve her user fact during the distribution check.
|
|
206
|
+
const malloryId = 'mallory-3-' + randomSuffix();
|
|
207
|
+
await asUser(withSession, malloryId, async (j) => { await j.login(); });
|
|
208
|
+
|
|
209
|
+
// Alice subscribes while NOT yet an admin so the cached hash is the
|
|
210
|
+
// intersected (Alice-owned) spec.
|
|
211
|
+
const feedsResponse = await request(app)
|
|
212
|
+
.post('/feeds')
|
|
213
|
+
.set('Content-Type', 'text/plain')
|
|
214
|
+
.set('x-test-user-id', aliceId)
|
|
215
|
+
.send(officeSpecText(companyHash));
|
|
216
|
+
const { feeds } = JSON.parse(feedsResponse.text);
|
|
217
|
+
const intersectedHash = feeds[0];
|
|
218
|
+
|
|
219
|
+
// Now grant Alice the Administrator role so her intersected spec
|
|
220
|
+
// actually produces rows.
|
|
221
|
+
await asUser(withSession, creatorId, async (creatorJ) => {
|
|
222
|
+
await creatorJ.fact(new Administrator(companyValue, aliceFact, new Date('2026-05-27')));
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// Alice can read her own intersected feed and sees the office.
|
|
226
|
+
const alicePoll = await request(app)
|
|
227
|
+
.get(`/feeds/${intersectedHash}`)
|
|
228
|
+
.set('x-test-user-id', aliceId);
|
|
229
|
+
expect(alicePoll.status).toBe(200);
|
|
230
|
+
const aliceBody = JSON.parse(alicePoll.text);
|
|
231
|
+
expect((aliceBody.references || []).some(r => r.type === Office.Type)).toBe(true);
|
|
232
|
+
|
|
233
|
+
// Mallory polls the same hash. The router routes her through the
|
|
234
|
+
// normal distribution check (cached owner is Alice, not Mallory),
|
|
235
|
+
// which denies her — the polling path returns an empty page rather
|
|
236
|
+
// than leaking Alice's data.
|
|
237
|
+
const malloryPoll = await request(app)
|
|
238
|
+
.get(`/feeds/${intersectedHash}`)
|
|
239
|
+
.set('x-test-user-id', malloryId);
|
|
240
|
+
expect(malloryPoll.status).toBe(200);
|
|
241
|
+
const malloryBody = JSON.parse(malloryPoll.text);
|
|
242
|
+
const malloryOffices = (malloryBody.references || []).filter(r => r.type === Office.Type);
|
|
243
|
+
expect(malloryOffices).toHaveLength(0);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
const request = require('supertest');
|
|
2
|
+
const { buildModel, User } = require('jinaga');
|
|
3
|
+
const { createSubscriptionApp, asUser, randomSuffix } = require('./subscription-helpers');
|
|
4
|
+
|
|
5
|
+
// Validates the "keep subscriptions alive across distribution failures"
|
|
6
|
+
// contract: when no distribution rule covers the requested spec, the
|
|
7
|
+
// /feeds endpoint accepts the subscription and polling returns an empty
|
|
8
|
+
// page rather than 403. /read still 403s because reads are one-shot.
|
|
9
|
+
|
|
10
|
+
class Company {
|
|
11
|
+
static Type = "NoRule.Company";
|
|
12
|
+
type = Company.Type;
|
|
13
|
+
constructor(creator, identifier) {
|
|
14
|
+
this.creator = creator;
|
|
15
|
+
this.identifier = identifier;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
class Office {
|
|
20
|
+
static Type = "NoRule.Office";
|
|
21
|
+
type = Office.Type;
|
|
22
|
+
constructor(company, name) {
|
|
23
|
+
this.company = company;
|
|
24
|
+
this.name = name;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class Administrator {
|
|
29
|
+
static Type = "NoRule.Administrator";
|
|
30
|
+
type = Administrator.Type;
|
|
31
|
+
constructor(company, user, createdAt) {
|
|
32
|
+
this.company = company;
|
|
33
|
+
this.user = user;
|
|
34
|
+
this.createdAt = createdAt;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const model = buildModel(b => b
|
|
39
|
+
.type(User)
|
|
40
|
+
.type(Company, m => m.predecessor("creator", User))
|
|
41
|
+
.type(Office, m => m.predecessor("company", Company))
|
|
42
|
+
.type(Administrator, m => m
|
|
43
|
+
.predecessor("company", Company)
|
|
44
|
+
.predecessor("user", User))
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
function authorization(a) {
|
|
48
|
+
return a
|
|
49
|
+
.any(User)
|
|
50
|
+
.any(Company)
|
|
51
|
+
.any(Office)
|
|
52
|
+
.any(Administrator);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Distribution covers Office only. Administrator queries fall outside
|
|
56
|
+
// any rule.
|
|
57
|
+
function distribution(d) {
|
|
58
|
+
return d
|
|
59
|
+
.share(model.given(Company).match((c, facts) =>
|
|
60
|
+
facts.ofType(Office).join(o => o.company, c)
|
|
61
|
+
))
|
|
62
|
+
.with(model.given(Company).match((c, facts) =>
|
|
63
|
+
facts.ofType(Administrator)
|
|
64
|
+
.join(a => a.company, c)
|
|
65
|
+
.selectMany(a => facts.ofType(User).join(u => u, a.user))
|
|
66
|
+
));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function administratorSpecText(companyHash) {
|
|
70
|
+
return `let p1: ${Company.Type} = #${companyHash}\n` +
|
|
71
|
+
`(p1: ${Company.Type}) {\n` +
|
|
72
|
+
` a: ${Administrator.Type} [\n` +
|
|
73
|
+
` a->company: ${Company.Type} = p1\n` +
|
|
74
|
+
` ]\n` +
|
|
75
|
+
`} => a`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe('Subscription when no distribution rule applies', () => {
|
|
79
|
+
let app, withSession, close;
|
|
80
|
+
let companyHash;
|
|
81
|
+
let subscriberId;
|
|
82
|
+
|
|
83
|
+
beforeEach(async () => {
|
|
84
|
+
({ app, withSession, close } = await createSubscriptionApp({
|
|
85
|
+
model, authorization, distribution
|
|
86
|
+
}));
|
|
87
|
+
|
|
88
|
+
const creatorId = 'creator-norule-' + randomSuffix();
|
|
89
|
+
({ companyHash } = await asUser(withSession, creatorId, async (creatorJ) => {
|
|
90
|
+
const { userFact: creatorFact } = await creatorJ.login();
|
|
91
|
+
const company = await creatorJ.fact(new Company(creatorFact, 'Acme-' + randomSuffix()));
|
|
92
|
+
return { companyHash: creatorJ.hash(company) };
|
|
93
|
+
}));
|
|
94
|
+
|
|
95
|
+
subscriberId = 'subscriber-norule-' + randomSuffix();
|
|
96
|
+
await asUser(withSession, subscriberId, async (j) => { await j.login(); });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
afterEach(async () => {
|
|
100
|
+
if (close) await close();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('accepts /feeds and returns an empty page on poll', async () => {
|
|
104
|
+
// No rule covers Administrator. intersectForSubscribe also has
|
|
105
|
+
// nothing to intersect against. The contract is: /feeds succeeds,
|
|
106
|
+
// polling returns an empty page so the client keeps waiting.
|
|
107
|
+
const feedsResponse = await request(app)
|
|
108
|
+
.post('/feeds')
|
|
109
|
+
.set('Content-Type', 'text/plain')
|
|
110
|
+
.set('x-test-user-id', subscriberId)
|
|
111
|
+
.send(administratorSpecText(companyHash));
|
|
112
|
+
|
|
113
|
+
expect(feedsResponse.status).toBe(200);
|
|
114
|
+
const { feeds } = JSON.parse(feedsResponse.text);
|
|
115
|
+
expect(Array.isArray(feeds)).toBe(true);
|
|
116
|
+
expect(feeds.length).toBeGreaterThan(0);
|
|
117
|
+
|
|
118
|
+
const pollResponse = await request(app)
|
|
119
|
+
.get(`/feeds/${feeds[0]}`)
|
|
120
|
+
.set('x-test-user-id', subscriberId);
|
|
121
|
+
expect(pollResponse.status).toBe(200);
|
|
122
|
+
const body = JSON.parse(pollResponse.text);
|
|
123
|
+
expect(body.references || []).toEqual([]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('still returns 403 from /read for the same spec', async () => {
|
|
127
|
+
// Reads are one-shot — there is no "wait until authorized later"
|
|
128
|
+
// semantic, so Forbidden propagates as 403.
|
|
129
|
+
const readResponse = await request(app)
|
|
130
|
+
.post('/read')
|
|
131
|
+
.set('Content-Type', 'text/plain')
|
|
132
|
+
.set('x-test-user-id', subscriberId)
|
|
133
|
+
.send(administratorSpecText(companyHash));
|
|
134
|
+
|
|
135
|
+
expect(readResponse.status).toBe(403);
|
|
136
|
+
});
|
|
137
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jinaga-server",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.6.0",
|
|
4
4
|
"description": "Data management for web and mobile applications.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"csv-stringify": "^6.6.0",
|
|
51
51
|
"express": "^4.21.2",
|
|
52
|
-
"jinaga": "^6.
|
|
52
|
+
"jinaga": "^6.8.0",
|
|
53
53
|
"pg": "^8.13.1"
|
|
54
54
|
}
|
|
55
55
|
}
|
|
@@ -17,10 +17,43 @@ import {
|
|
|
17
17
|
factReferenceEquals
|
|
18
18
|
} from "jinaga";
|
|
19
19
|
|
|
20
|
+
export interface DistributionIntersectionBranch {
|
|
21
|
+
start: FactReference[];
|
|
22
|
+
specification: Specification;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type DistributionBranchesResult =
|
|
26
|
+
| { type: "success"; branches: DistributionIntersectionBranch[] }
|
|
27
|
+
| { type: "denied"; reason: string };
|
|
28
|
+
|
|
29
|
+
export type FeedResult =
|
|
30
|
+
| { type: "success"; feed: FactFeed }
|
|
31
|
+
| { type: "denied"; reason: string };
|
|
32
|
+
|
|
33
|
+
export interface SubscriptionAuthorizer {
|
|
34
|
+
verifyDistributionOrIntersect(
|
|
35
|
+
userIdentity: UserIdentity | null,
|
|
36
|
+
specification: Specification,
|
|
37
|
+
namedStart: ReferencesByName
|
|
38
|
+
): Promise<DistributionBranchesResult>;
|
|
39
|
+
feedPreVerified(
|
|
40
|
+
userIdentity: UserIdentity | null,
|
|
41
|
+
specification: Specification,
|
|
42
|
+
start: FactReference[],
|
|
43
|
+
bookmark: string
|
|
44
|
+
): Promise<FactFeed>;
|
|
45
|
+
feedWithDistribution(
|
|
46
|
+
userIdentity: UserIdentity | null,
|
|
47
|
+
specification: Specification,
|
|
48
|
+
start: FactReference[],
|
|
49
|
+
bookmark: string
|
|
50
|
+
): Promise<FeedResult>;
|
|
51
|
+
}
|
|
52
|
+
|
|
20
53
|
import { Keystore } from "../keystore";
|
|
21
54
|
import { DistributedFactCache } from "./distributed-fact-cache";
|
|
22
55
|
|
|
23
|
-
export class AuthorizationKeystore implements Authorization {
|
|
56
|
+
export class AuthorizationKeystore implements Authorization, SubscriptionAuthorizer {
|
|
24
57
|
private authorizationEngine: AuthorizationEngine | null;
|
|
25
58
|
private distributionEngine: DistributionEngine | null;
|
|
26
59
|
private distributedFacts: DistributedFactCache = new DistributedFactCache();
|
|
@@ -70,6 +103,14 @@ export class AuthorizationKeystore implements Authorization {
|
|
|
70
103
|
}
|
|
71
104
|
|
|
72
105
|
async feed(userIdentity: UserIdentity | null, specification: Specification, start: FactReference[], bookmark: string): Promise<FactFeed> {
|
|
106
|
+
const result = await this.feedWithDistribution(userIdentity, specification, start, bookmark);
|
|
107
|
+
if (result.type === "denied") {
|
|
108
|
+
throw new Forbidden(result.reason);
|
|
109
|
+
}
|
|
110
|
+
return result.feed;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async feedWithDistribution(userIdentity: UserIdentity | null, specification: Specification, start: FactReference[], bookmark: string): Promise<FeedResult> {
|
|
73
114
|
if (this.distributionEngine) {
|
|
74
115
|
const userReference: FactReference | null = userIdentity
|
|
75
116
|
? await this.keystore.getUserFact(userIdentity)
|
|
@@ -80,18 +121,35 @@ export class AuthorizationKeystore implements Authorization {
|
|
|
80
121
|
}), {} as ReferencesByName);
|
|
81
122
|
const canDistribute = await this.distributionEngine.canDistributeToAll([specification], namedStart, userReference);
|
|
82
123
|
if (canDistribute.type === "failure") {
|
|
83
|
-
|
|
124
|
+
return { type: "denied", reason: canDistribute.reason };
|
|
84
125
|
}
|
|
85
126
|
const factFeed = await this.store.feed(specification, start, bookmark);
|
|
86
127
|
const factReferences = factFeed.tuples
|
|
87
128
|
.flatMap(tuple => tuple.facts)
|
|
88
129
|
.filter((value, index, self) => self.findIndex(factReferenceEquals(value)) === index);
|
|
89
130
|
this.distributedFacts.add(factReferences, userReference);
|
|
90
|
-
return factFeed;
|
|
131
|
+
return { type: "success", feed: factFeed };
|
|
91
132
|
}
|
|
92
|
-
|
|
93
|
-
|
|
133
|
+
return { type: "success", feed: await this.store.feed(specification, start, bookmark) };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async feedPreVerified(userIdentity: UserIdentity | null, specification: Specification, start: FactReference[], bookmark: string): Promise<FactFeed> {
|
|
137
|
+
// Caller has already established that this feed is safe to serve
|
|
138
|
+
// (e.g. produced by intersectForSubscribe — the spec lifts its own
|
|
139
|
+
// authorization condition into the matches and therefore self-
|
|
140
|
+
// filters). Skip the redundant per-query distribution check, but
|
|
141
|
+
// keep tracking the returned references so /load stays consistent.
|
|
142
|
+
const factFeed = await this.store.feed(specification, start, bookmark);
|
|
143
|
+
if (this.distributionEngine) {
|
|
144
|
+
const userReference: FactReference | null = userIdentity
|
|
145
|
+
? await this.keystore.getUserFact(userIdentity)
|
|
146
|
+
: null;
|
|
147
|
+
const factReferences = factFeed.tuples
|
|
148
|
+
.flatMap(tuple => tuple.facts)
|
|
149
|
+
.filter((value, index, self) => self.findIndex(factReferenceEquals(value)) === index);
|
|
150
|
+
this.distributedFacts.add(factReferences, userReference);
|
|
94
151
|
}
|
|
152
|
+
return factFeed;
|
|
95
153
|
}
|
|
96
154
|
|
|
97
155
|
async load(userIdentity: UserIdentity, references: FactReference[]) {
|
|
@@ -165,4 +223,28 @@ export class AuthorizationKeystore implements Authorization {
|
|
|
165
223
|
throw new Forbidden(canDistribute.reason);
|
|
166
224
|
}
|
|
167
225
|
}
|
|
226
|
+
|
|
227
|
+
async verifyDistributionOrIntersect(
|
|
228
|
+
userIdentity: UserIdentity | null,
|
|
229
|
+
specification: Specification,
|
|
230
|
+
namedStart: ReferencesByName
|
|
231
|
+
): Promise<DistributionBranchesResult> {
|
|
232
|
+
const start = specification.given.map(g => namedStart[g.label.name]);
|
|
233
|
+
if (!this.distributionEngine) {
|
|
234
|
+
return { type: "success", branches: [{ start, specification }] };
|
|
235
|
+
}
|
|
236
|
+
const userReference: FactReference | null = userIdentity
|
|
237
|
+
? await this.keystore.getUserFact(userIdentity)
|
|
238
|
+
: null;
|
|
239
|
+
const targetFeeds = buildFeeds(specification);
|
|
240
|
+
const canDistribute = await this.distributionEngine.canDistributeToAll(targetFeeds, namedStart, userReference);
|
|
241
|
+
if (canDistribute.type === "success") {
|
|
242
|
+
return { type: "success", branches: [{ start, specification }] };
|
|
243
|
+
}
|
|
244
|
+
const result = await this.distributionEngine.intersectForSubscribe(start, specification, userReference);
|
|
245
|
+
if (!result.intersected) {
|
|
246
|
+
return { type: "denied", reason: canDistribute.reason };
|
|
247
|
+
}
|
|
248
|
+
return { type: "success", branches: result.branches };
|
|
249
|
+
}
|
|
168
250
|
}
|