jinaga-server 3.5.5 → 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 (36) hide show
  1. package/dist/authorization/authorization-keystore.d.ts +27 -1
  2. package/dist/authorization/authorization-keystore.js +52 -4
  3. package/dist/authorization/authorization-keystore.js.map +1 -1
  4. package/dist/http/router.d.ts +4 -1
  5. package/dist/http/router.js +157 -14
  6. package/dist/http/router.js.map +1 -1
  7. package/dist/jinaga-server.js +18 -1
  8. package/dist/jinaga-server.js.map +1 -1
  9. package/dist/memory/memory-keystore.js +1 -1
  10. package/dist/memory/memory-keystore.js.map +1 -1
  11. package/dist/postgres/query-description.d.ts +1 -0
  12. package/dist/postgres/query-description.js +34 -3
  13. package/dist/postgres/query-description.js.map +1 -1
  14. package/dist/postgres/specification-sql.js +7 -1
  15. package/dist/postgres/specification-sql.js.map +1 -1
  16. package/integration-test/jinaga-test/distribution-multi-given.test.js +184 -0
  17. package/integration-test/jinaga-test/distribution-negating-feed.test.js +202 -0
  18. package/integration-test/jinaga-test/feed-existential-input.test.js +241 -0
  19. package/integration-test/jinaga-test/feeds-endpoint.test.js +140 -0
  20. package/integration-test/jinaga-test/package-lock.json +4 -4
  21. package/integration-test/jinaga-test/package.json +3 -2
  22. package/integration-test/jinaga-test/subscription-anchor-arrival.test.js +129 -0
  23. package/integration-test/jinaga-test/subscription-helpers.js +60 -0
  24. package/integration-test/jinaga-test/subscription-late-auth.test.js +245 -0
  25. package/integration-test/jinaga-test/subscription-no-rule.test.js +137 -0
  26. package/package.json +2 -2
  27. package/src/authorization/authorization-keystore.ts +87 -5
  28. package/src/http/router.ts +174 -21
  29. package/src/jinaga-server.ts +36 -3
  30. package/src/memory/memory-keystore.ts +1 -1
  31. package/src/postgres/query-description.ts +66 -11
  32. package/src/postgres/specification-sql.ts +7 -1
  33. package/test/authorization/subscriptionIntersectionSpec.ts +208 -0
  34. package/test/http/anchorListenerSpec.ts +117 -0
  35. package/test/http/lateAuthRecoverySpec.ts +245 -0
  36. package/test/http/streamFeedAnchorSpec.ts +169 -0
@@ -0,0 +1,129 @@
1
+ const request = require('supertest');
2
+ const { buildModel, dehydrateFact, User } = require('jinaga');
3
+ const { createSubscriptionApp, asUser, randomSuffix } = require('./subscription-helpers');
4
+
5
+ // Validates the polling-side contract that subscribing to a spec whose
6
+ // given fact has not yet been stored is accepted, and that the data
7
+ // surfaces once the given (and a matching descendant) lands. In the
8
+ // streaming path this is what the anchor listener wakes up; the polling
9
+ // path achieves the same outcome by re-querying the store, so a Postgres-
10
+ // backed test here guards the store/feed query against a missing given
11
+ // reference.
12
+
13
+ class Note {
14
+ static Type = "AnchorArrival.Note";
15
+ type = Note.Type;
16
+ constructor(author, body) {
17
+ this.author = author;
18
+ this.body = body;
19
+ }
20
+ }
21
+
22
+ const model = buildModel(b => b
23
+ .type(User)
24
+ .type(Note, m => m.predecessor("author", User))
25
+ );
26
+
27
+ function authorization(a) {
28
+ return a.any(User).any(Note);
29
+ }
30
+
31
+ function noteSpecText(userHash) {
32
+ return `let p: Jinaga.User = #${userHash}\n` +
33
+ `(p: Jinaga.User) {\n` +
34
+ ` n: ${Note.Type} [\n` +
35
+ ` n->author: Jinaga.User = p\n` +
36
+ ` ]\n` +
37
+ `} => n`;
38
+ }
39
+
40
+ describe('Subscription with not-yet-stored given fact', () => {
41
+ let app, withSession, close;
42
+
43
+ beforeEach(async () => {
44
+ ({ app, withSession, close } = await createSubscriptionApp({
45
+ model, authorization
46
+ }));
47
+ });
48
+
49
+ afterEach(async () => {
50
+ if (close) await close();
51
+ });
52
+
53
+ it('accepts the subscription and surfaces data once the given and a descendant arrive', async () => {
54
+ // Hand-craft a User whose hash is known but whose fact has not been
55
+ // saved. This stands in for the "given fact arrives later" scenario
56
+ // — the streamFeed path would activate via the anchor listener; the
57
+ // polling path picks it up naturally on the next poll.
58
+ const externalUser = new User('anchor-arrival-key-' + randomSuffix());
59
+ const userHash = dehydrateFact(externalUser)
60
+ .find(f => f.type === 'Jinaga.User').hash;
61
+
62
+ const subscriberId = 'anchor-subscriber-' + randomSuffix();
63
+ await asUser(withSession, subscriberId, async (j) => { await j.login(); });
64
+
65
+ const feedsResponse = await request(app)
66
+ .post('/feeds')
67
+ .set('Content-Type', 'text/plain')
68
+ .set('x-test-user-id', subscriberId)
69
+ .send(noteSpecText(userHash));
70
+ expect(feedsResponse.status).toBe(200);
71
+ const { feeds } = JSON.parse(feedsResponse.text);
72
+ expect(feeds.length).toBeGreaterThan(0);
73
+ const feedHash = feeds[0];
74
+
75
+ // Poll #1: the given fact does not exist yet, so there can be no
76
+ // descendants — the page is empty but the server must not 404 or
77
+ // error.
78
+ const firstPoll = await request(app)
79
+ .get(`/feeds/${feedHash}`)
80
+ .set('x-test-user-id', subscriberId);
81
+ expect(firstPoll.status).toBe(200);
82
+ const firstBody = JSON.parse(firstPoll.text);
83
+ expect((firstBody.references || []).filter(r => r.type === Note.Type)).toHaveLength(0);
84
+ const firstBookmark = firstBody.bookmark || '';
85
+
86
+ // Save the given User fact AND a Note that joins to it.
87
+ // j.fact(new Note(externalUser, ...)) recursively saves both, so
88
+ // when polling resumes both the anchor and its descendant exist.
89
+ const writerId = 'anchor-writer-' + randomSuffix();
90
+ let noteHash;
91
+ await asUser(withSession, writerId, async (writerJ) => {
92
+ const note = await writerJ.fact(new Note(externalUser, 'first body'));
93
+ noteHash = writerJ.hash(note);
94
+ });
95
+
96
+ const secondPoll = await request(app)
97
+ .get(`/feeds/${feedHash}`)
98
+ .query({ b: firstBookmark })
99
+ .set('x-test-user-id', subscriberId);
100
+ expect(secondPoll.status).toBe(200);
101
+ const secondBody = JSON.parse(secondPoll.text);
102
+ const noteRefs = (secondBody.references || []).filter(r => r.type === Note.Type);
103
+ expect(noteRefs.map(r => r.hash)).toContain(noteHash);
104
+ });
105
+
106
+ it('returns an empty page (not 404) when the given fact never arrives', async () => {
107
+ const phantomUser = new User('phantom-key-' + randomSuffix());
108
+ const userHash = dehydrateFact(phantomUser)
109
+ .find(f => f.type === 'Jinaga.User').hash;
110
+
111
+ const subscriberId = 'phantom-subscriber-' + randomSuffix();
112
+ await asUser(withSession, subscriberId, async (j) => { await j.login(); });
113
+
114
+ const feedsResponse = await request(app)
115
+ .post('/feeds')
116
+ .set('Content-Type', 'text/plain')
117
+ .set('x-test-user-id', subscriberId)
118
+ .send(noteSpecText(userHash));
119
+ const { feeds } = JSON.parse(feedsResponse.text);
120
+ const feedHash = feeds[0];
121
+
122
+ const pollResponse = await request(app)
123
+ .get(`/feeds/${feedHash}`)
124
+ .set('x-test-user-id', subscriberId);
125
+ expect(pollResponse.status).toBe(200);
126
+ const body = JSON.parse(pollResponse.text);
127
+ expect((body.references || []).filter(r => r.type === Note.Type)).toHaveLength(0);
128
+ });
129
+ });
@@ -0,0 +1,60 @@
1
+ const express = require('express');
2
+ const { JinagaServer } = require('./jinaga-server');
3
+
4
+ const host = "db";
5
+ const connectionString = `postgresql://dev:devpw@${host}:5432/integrationtest`;
6
+
7
+ function authMiddleware(req, res, next) {
8
+ const userId = req.header('x-test-user-id');
9
+ if (userId) {
10
+ req.user = {
11
+ provider: 'test',
12
+ id: userId,
13
+ profile: { displayName: userId }
14
+ };
15
+ }
16
+ next();
17
+ }
18
+
19
+ async function createSubscriptionApp(config) {
20
+ const instance = JinagaServer.create({
21
+ pgKeystore: connectionString,
22
+ pgStore: connectionString,
23
+ ...config
24
+ });
25
+
26
+ const app = express();
27
+ app.use(express.json());
28
+ app.use(express.text());
29
+ app.use(authMiddleware);
30
+ app.use(instance.handler);
31
+
32
+ await instance.j.local();
33
+ return { app, ...instance };
34
+ }
35
+
36
+ async function asUser(withSession, userId, callback) {
37
+ let result;
38
+ await withSession(
39
+ { user: { provider: 'test', id: userId, profile: { displayName: userId } } },
40
+ async (j) => {
41
+ // Ensure the user fact is registered in the keystore so AuthenticationSession
42
+ // can sign facts and the router's getUserFact succeeds on later requests.
43
+ await j.login();
44
+ result = await callback(j);
45
+ }
46
+ );
47
+ return result;
48
+ }
49
+
50
+ function randomSuffix() {
51
+ return Math.random().toString(36).substring(2, 10);
52
+ }
53
+
54
+ module.exports = {
55
+ connectionString,
56
+ authMiddleware,
57
+ createSubscriptionApp,
58
+ asUser,
59
+ randomSuffix
60
+ };
@@ -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.5.5",
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.7.25",
52
+ "jinaga": "^6.8.0",
53
53
  "pg": "^8.13.1"
54
54
  }
55
55
  }