jinaga-server 3.5.4 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/.github/workflows/publish.yml +5 -2
  2. package/.github/workflows/upgrade-jinaga.yml +51 -0
  3. package/CLAUDE.md +77 -0
  4. package/dist/authorization/authorization-keystore.d.ts +27 -1
  5. package/dist/authorization/authorization-keystore.js +52 -4
  6. package/dist/authorization/authorization-keystore.js.map +1 -1
  7. package/dist/http/router.d.ts +4 -1
  8. package/dist/http/router.js +157 -14
  9. package/dist/http/router.js.map +1 -1
  10. package/dist/jinaga-server.js +18 -1
  11. package/dist/jinaga-server.js.map +1 -1
  12. package/dist/memory/memory-keystore.js +1 -1
  13. package/dist/memory/memory-keystore.js.map +1 -1
  14. package/dist/postgres/query-description.d.ts +1 -0
  15. package/dist/postgres/query-description.js +34 -3
  16. package/dist/postgres/query-description.js.map +1 -1
  17. package/dist/postgres/specification-sql.js +7 -1
  18. package/dist/postgres/specification-sql.js.map +1 -1
  19. package/docs/analysis/feed-builder-nested-existential-bug.md +191 -0
  20. package/integration-test/jinaga-test/distribution-multi-given.test.js +184 -0
  21. package/integration-test/jinaga-test/distribution-negating-feed.test.js +202 -0
  22. package/integration-test/jinaga-test/feed-existential-input.test.js +241 -0
  23. package/integration-test/jinaga-test/feeds-endpoint.test.js +140 -0
  24. package/integration-test/jinaga-test/package-lock.json +4 -4
  25. package/integration-test/jinaga-test/package.json +3 -2
  26. package/integration-test/jinaga-test/subscription-anchor-arrival.test.js +129 -0
  27. package/integration-test/jinaga-test/subscription-helpers.js +60 -0
  28. package/integration-test/jinaga-test/subscription-late-auth.test.js +245 -0
  29. package/integration-test/jinaga-test/subscription-no-rule.test.js +137 -0
  30. package/package.json +2 -2
  31. package/src/authorization/authorization-keystore.ts +87 -5
  32. package/src/http/router.ts +174 -21
  33. package/src/jinaga-server.ts +36 -3
  34. package/src/memory/memory-keystore.ts +1 -1
  35. package/src/postgres/query-description.ts +66 -11
  36. package/src/postgres/specification-sql.ts +7 -1
  37. package/test/authorization/subscriptionIntersectionSpec.ts +208 -0
  38. package/test/http/anchorListenerSpec.ts +117 -0
  39. package/test/http/lateAuthRecoverySpec.ts +245 -0
  40. package/test/http/streamFeedAnchorSpec.ts +169 -0
@@ -0,0 +1,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.4",
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.14",
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
- throw new Forbidden(canDistribute.reason);
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
- else {
93
- return await this.store.feed(specification, start, bookmark);
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
  }