jinaga-server 3.5.5 → 3.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/authorization/authorization-keystore.d.ts +34 -2
  2. package/dist/authorization/authorization-keystore.js +59 -4
  3. package/dist/authorization/authorization-keystore.js.map +1 -1
  4. package/dist/feeds/feed-stream-session.d.ts +84 -0
  5. package/dist/feeds/feed-stream-session.js +331 -0
  6. package/dist/feeds/feed-stream-session.js.map +1 -0
  7. package/dist/http/router.d.ts +10 -3
  8. package/dist/http/router.js +186 -184
  9. package/dist/http/router.js.map +1 -1
  10. package/dist/index.d.ts +2 -1
  11. package/dist/index.js +5 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/jinaga-server.d.ts +7 -0
  14. package/dist/jinaga-server.js +19 -2
  15. package/dist/jinaga-server.js.map +1 -1
  16. package/dist/memory/memory-keystore.js +1 -1
  17. package/dist/memory/memory-keystore.js.map +1 -1
  18. package/dist/postgres/query-description.d.ts +1 -0
  19. package/dist/postgres/query-description.js +34 -3
  20. package/dist/postgres/query-description.js.map +1 -1
  21. package/dist/postgres/specification-sql.js +7 -1
  22. package/dist/postgres/specification-sql.js.map +1 -1
  23. package/docs/design/feeds-persistent-proxy-plan.md +11 -1
  24. package/integration-test/jinaga-test/distribution-multi-given.test.js +184 -0
  25. package/integration-test/jinaga-test/distribution-negating-feed.test.js +202 -0
  26. package/integration-test/jinaga-test/feed-existential-input.test.js +241 -0
  27. package/integration-test/jinaga-test/feeds-endpoint.test.js +140 -0
  28. package/integration-test/jinaga-test/package-lock.json +4 -4
  29. package/integration-test/jinaga-test/package.json +3 -2
  30. package/integration-test/jinaga-test/subscription-anchor-arrival.test.js +129 -0
  31. package/integration-test/jinaga-test/subscription-helpers.js +60 -0
  32. package/integration-test/jinaga-test/subscription-late-auth.test.js +245 -0
  33. package/integration-test/jinaga-test/subscription-no-rule.test.js +137 -0
  34. package/package.json +2 -2
  35. package/src/authorization/authorization-keystore.ts +106 -5
  36. package/src/feeds/feed-stream-session.ts +386 -0
  37. package/src/http/router.ts +212 -237
  38. package/src/index.ts +2 -1
  39. package/src/jinaga-server.ts +45 -5
  40. package/src/memory/memory-keystore.ts +1 -1
  41. package/src/postgres/query-description.ts +66 -11
  42. package/src/postgres/specification-sql.ts +7 -1
  43. package/test/authorization/intermediateProjectionFeedSpec.ts +189 -0
  44. package/test/authorization/subscriptionIntersectionSpec.ts +220 -0
  45. package/test/feeds/feedStreamSessionRegressionSpec.ts +227 -0
  46. package/test/feeds/feedStreamSessionSpec.ts +351 -0
  47. package/test/http/anchorListenerSpec.ts +117 -0
  48. package/test/http/feedStreamSessionIntegrationSpec.ts +202 -0
  49. package/test/http/lateAuthRecoverySpec.ts +435 -0
  50. package/test/http/streamFeedAnchorSpec.ts +171 -0
@@ -5,7 +5,9 @@ import {
5
5
  AuthorizationNoOp,
6
6
  AuthorizationRules,
7
7
  DistributionRules,
8
+ FactFeed,
8
9
  FactManager,
10
+ FactReference,
9
11
  FeedCache,
10
12
  FetchConnection,
11
13
  Fork,
@@ -20,6 +22,7 @@ import {
20
22
  PassThroughFork,
21
23
  PersistentFork,
22
24
  PurgeConditions,
25
+ ReferencesByName,
23
26
  Specification,
24
27
  Storage,
25
28
  SyncStatusNotifier,
@@ -33,7 +36,8 @@ import { Pool } from "pg";
33
36
 
34
37
  import { AuthenticationDevice } from "./authentication/authentication-device";
35
38
  import { AuthenticationSession } from "./authentication/authentication-session";
36
- import { AuthorizationKeystore } from "./authorization/authorization-keystore";
39
+ import { AuthorizationKeystore, DistributionBranchesResult, FeedResult, SubscriptionAuthorizer } from "./authorization/authorization-keystore";
40
+ import { FeedStreamSessionConfig } from "./feeds/feed-stream-session";
37
41
  import { HttpRouter, RequestUser } from "./http/router";
38
42
  import { Keystore } from "./keystore";
39
43
  import { PostgresKeystore } from "./postgres/postgres-keystore";
@@ -53,7 +57,13 @@ export type JinagaServerConfig = {
53
57
  authorization?: (a: AuthorizationRules) => AuthorizationRules,
54
58
  distribution?: (d: DistributionRules) => DistributionRules,
55
59
  purgeConditions?: (p: PurgeConditions) => PurgeConditions,
56
- origin?: string | string[] | ((origin: string, callback: (err: Error | null, allow?: boolean) => void) => void)
60
+ origin?: string | string[] | ((origin: string, callback: (err: Error | null, allow?: boolean) => void) => void),
61
+ /**
62
+ * Tuning for the per-connection feed streaming session
63
+ * (/feeds/:hash with Accept: application/x-jinaga-feed-stream).
64
+ * See FeedStreamSessionConfig for the available limits and defaults.
65
+ */
66
+ feedStream?: Partial<FeedStreamSessionConfig>
57
67
  };
58
68
 
59
69
  export type JinagaServerInstance = {
@@ -88,7 +98,7 @@ export class JinagaServer {
88
98
  const purgeConditions = createPurgeConditions(config);
89
99
  const factManager = new FactManager(fork, source, store, network, purgeConditions);
90
100
  const authorization = createAuthorization(authorizationRules, distributionRules, factManager, store, keystore);
91
- const router = new HttpRouter(factManager, authorization, feedCache, config.origin || '*');
101
+ const router = new HttpRouter(factManager, authorization, feedCache, config.origin || '*', config.feedStream || {});
92
102
  const j: Jinaga = new Jinaga(authentication, factManager, syncStatusNotifier);
93
103
 
94
104
  async function close() {
@@ -186,13 +196,43 @@ function createKeystore(config: JinagaServerConfig, pools: { [uri: string]: Pool
186
196
  }
187
197
  }
188
198
 
189
- function createAuthorization(authorizationRules: AuthorizationRules | null, distributionRules: DistributionRules | null, factManager: FactManager, store: Storage, keystore: Keystore | null): Authorization {
199
+ class AuthorizationNoOpWithSubscriptions extends AuthorizationNoOp implements SubscriptionAuthorizer {
200
+ async verifyDistributionOrIntersect(
201
+ _userIdentity: UserIdentity | null,
202
+ specification: Specification,
203
+ namedStart: ReferencesByName
204
+ ): Promise<DistributionBranchesResult> {
205
+ const start = specification.given.map(g => namedStart[g.label.name]);
206
+ return { type: "success", branches: [{ start, specification }] };
207
+ }
208
+
209
+ feedPreVerified(
210
+ userIdentity: UserIdentity | null,
211
+ specification: Specification,
212
+ start: FactReference[],
213
+ bookmark: string
214
+ ): Promise<FactFeed> {
215
+ return this.feed(userIdentity!, specification, start, bookmark);
216
+ }
217
+
218
+ async feedWithDistribution(
219
+ userIdentity: UserIdentity | null,
220
+ specification: Specification,
221
+ start: FactReference[],
222
+ bookmark: string
223
+ ): Promise<FeedResult> {
224
+ const feed = await this.feed(userIdentity!, specification, start, bookmark);
225
+ return { type: "success", feed };
226
+ }
227
+ }
228
+
229
+ function createAuthorization(authorizationRules: AuthorizationRules | null, distributionRules: DistributionRules | null, factManager: FactManager, store: Storage, keystore: Keystore | null): Authorization & SubscriptionAuthorizer {
190
230
  if (keystore) {
191
231
  const authorization = new AuthorizationKeystore(factManager, store, keystore, authorizationRules, distributionRules);
192
232
  return authorization;
193
233
  }
194
234
  else {
195
- return new AuthorizationNoOp(factManager, store);
235
+ return new AuthorizationNoOpWithSubscriptions(factManager, store);
196
236
  }
197
237
  }
198
238
 
@@ -75,6 +75,6 @@ export class MemoryKeystore implements Keystore {
75
75
  private generateKeyPair(key: string) {
76
76
  const keyPair = generateKeyPair();
77
77
  this.keyPairs[key] = keyPair;
78
- return keyPair.privatePem;
78
+ return keyPair.publicPem;
79
79
  }
80
80
  }
@@ -116,6 +116,40 @@ export class QueryDescription {
116
116
  }
117
117
  }
118
118
 
119
+ public withInputOnExistingFact(label: Label, factTypeId: number, factHash: string, factIndex: number, path: number[]): QueryDescription {
120
+ const factTypeParameter = this.parameters.length + 1;
121
+ const factHashParameter = factTypeParameter + 1;
122
+ const input: InputDescription = {
123
+ label: label.name,
124
+ type: label.type,
125
+ factIndex,
126
+ factTypeParameter,
127
+ factHashParameter
128
+ };
129
+ const parameters = this.parameters.concat(factTypeId, factHash);
130
+ if (path.length === 0) {
131
+ return new QueryDescription(
132
+ [...this.inputs, input],
133
+ parameters,
134
+ this.outputs,
135
+ this.facts,
136
+ this.edges,
137
+ this.existentialConditions
138
+ );
139
+ }
140
+ else {
141
+ const existentialConditions = existentialsWithInput(this.existentialConditions, input, path);
142
+ return new QueryDescription(
143
+ this.inputs,
144
+ parameters,
145
+ this.outputs,
146
+ this.facts,
147
+ this.edges,
148
+ existentialConditions
149
+ );
150
+ }
151
+ }
152
+
119
153
  public withFact(type: string): { query: QueryDescription; factIndex: number; } {
120
154
  const factIndex = this.facts.length + 1;
121
155
  const fact = { factIndex, type };
@@ -327,17 +361,38 @@ export class QueryDescriptionBuilder {
327
361
  if (!factTypeId) {
328
362
  return { queryDescription: QueryDescription.unsatisfiable, knownFacts };
329
363
  }
330
- const { queryDescription: newQueryDescription, factDescription } = queryDescription.withInputParameter(
331
- given[givenIndex],
332
- factTypeId,
333
- start[givenIndex].hash,
334
- path
335
- );
336
- queryDescription = newQueryDescription;
337
- knownFacts = {
338
- ...knownFacts,
339
- [condition.labelRight]: factDescription
340
- };
364
+ // When this is an identity path condition (no roles on either side)
365
+ // and the unknown has already been allocated, unify the given with
366
+ // the existing fact: register the type+hash filter on the existing
367
+ // factIndex rather than allocating a disconnected fact node.
368
+ const existingUnknown = knownFacts[unknown.name];
369
+ const isIdentity = condition.rolesLeft.length === 0 && condition.rolesRight.length === 0;
370
+ if (isIdentity && existingUnknown) {
371
+ queryDescription = queryDescription.withInputOnExistingFact(
372
+ given[givenIndex],
373
+ factTypeId,
374
+ start[givenIndex].hash,
375
+ existingUnknown.factIndex,
376
+ path
377
+ );
378
+ knownFacts = {
379
+ ...knownFacts,
380
+ [condition.labelRight]: existingUnknown
381
+ };
382
+ }
383
+ else {
384
+ const { queryDescription: newQueryDescription, factDescription } = queryDescription.withInputParameter(
385
+ given[givenIndex],
386
+ factTypeId,
387
+ start[givenIndex].hash,
388
+ path
389
+ );
390
+ queryDescription = newQueryDescription;
391
+ knownFacts = {
392
+ ...knownFacts,
393
+ [condition.labelRight]: factDescription
394
+ };
395
+ }
341
396
  }
342
397
 
343
398
  // Determine whether we have already written the output.
@@ -140,7 +140,13 @@ function generateNotExistsWhereClause(schema: string, notExistsWhereClause: Exis
140
140
  }
141
141
  const tailJoins: string[] = generateJoins(schema, notExistsWhereClause.edges.slice(1), writtenFactIndexes);
142
142
  const joins = firstJoin.concat(tailJoins);
143
- return `SELECT 1 FROM ${schema}.edge e${firstEdge.edgeIndex}${joins.join("")} WHERE ${whereClause.join(" AND ")}`;
143
+ const inputWhereClauses = notExistsWhereClause.inputs
144
+ .map(input => ` AND f${input.factIndex}.fact_type_id = $${input.factTypeParameter} AND f${input.factIndex}.hash = $${input.factHashParameter}`)
145
+ .join("");
146
+ const nestedExistsWhereClauses = notExistsWhereClause.existentialConditions
147
+ .map(nested => ` AND ${nested.exists ? "EXISTS" : "NOT EXISTS"} (${generateNotExistsWhereClause(schema, nested, writtenFactIndexes)})`)
148
+ .join("");
149
+ return `SELECT 1 FROM ${schema}.edge e${firstEdge.edgeIndex}${joins.join("")} WHERE ${whereClause.join(" AND ")}${inputWhereClauses}${nestedExistsWhereClauses}`;
144
150
  }
145
151
 
146
152
  function parseBookmark(bookmark: string): number[] {
@@ -0,0 +1,189 @@
1
+ import {
2
+ AuthorizationRules,
3
+ buildModel,
4
+ dehydrateFact,
5
+ DistributionRules,
6
+ FactManager,
7
+ Forbidden,
8
+ MemoryStore,
9
+ NetworkNoOp,
10
+ ObservableSource,
11
+ PassThroughFork,
12
+ User
13
+ } from "jinaga";
14
+
15
+ import { AuthorizationKeystore } from "../../src/authorization/authorization-keystore";
16
+ import { MemoryKeystore } from "../../src/memory/memory-keystore";
17
+
18
+ // Integration coverage for jinaga issue #204: a distribution rule whose
19
+ // `.select()` projection traverses *through* an intermediate fact (here
20
+ // `Finalist`, used only to reach the `competitor` it projects) must authorize an
21
+ // authorized user's direct query for that intermediate fact. The same
22
+ // DistributionEngine that JinagaTest uses also backs this server-side
23
+ // authorization path (`AuthorizationKeystore.read` -> `canDistributeToAll`,
24
+ // constructed with isTest=false), so the behavior must match.
25
+ //
26
+ // The sub-feed authorization relaxation this exercises landed in jinaga 6.8.2.
27
+
28
+ class Tenant {
29
+ public static Type = "Tenant" as const;
30
+ public type = Tenant.Type;
31
+ constructor(public creator: User) { }
32
+ }
33
+
34
+ class Administrator {
35
+ public static Type = "Administrator" as const;
36
+ public type = Administrator.Type;
37
+ constructor(public tenant: Tenant, public user: User, public createdAt: Date | string) { }
38
+ }
39
+
40
+ class Event {
41
+ public static Type = "Event" as const;
42
+ public type = Event.Type;
43
+ constructor(public tenant: Tenant, public id: string) { }
44
+ }
45
+
46
+ class Competitor {
47
+ public static Type = "Competitor" as const;
48
+ public type = Competitor.Type;
49
+ constructor(public tenant: Tenant) { }
50
+ }
51
+
52
+ // Finalist is the intermediate: the projection joins Event -> Finalist only to
53
+ // reach the Competitor predecessor; Finalist is never a named component.
54
+ class Finalist {
55
+ public static Type = "Finalist" as const;
56
+ public type = Finalist.Type;
57
+ constructor(public competitor: Competitor, public event: Event) { }
58
+ }
59
+
60
+ class CompetitorName {
61
+ public static Type = "CompetitorName" as const;
62
+ public type = CompetitorName.Type;
63
+ constructor(public competitor: Competitor, public value: string, public prior: CompetitorName[]) { }
64
+ }
65
+
66
+ const model = buildModel(b => b
67
+ .type(User)
68
+ .type(Tenant, m => m.predecessor("creator", User))
69
+ .type(Administrator, m => m
70
+ .predecessor("tenant", Tenant)
71
+ .predecessor("user", User))
72
+ .type(Event, m => m.predecessor("tenant", Tenant))
73
+ .type(Competitor, m => m.predecessor("tenant", Tenant))
74
+ .type(Finalist, m => m
75
+ .predecessor("competitor", Competitor)
76
+ .predecessor("event", Event))
77
+ .type(CompetitorName, m => m
78
+ .predecessor("competitor", Competitor)
79
+ .predecessor("prior", CompetitorName))
80
+ );
81
+
82
+ const finalistsOfEvent = model.given(Event).match((event, facts) =>
83
+ facts.ofType(Finalist).join(finalist => finalist.event, event)
84
+ ).specification;
85
+
86
+ const subscriberIdentity = { provider: "mock", id: "subscriber" };
87
+ const creatorIdentity = { provider: "mock", id: "creator" };
88
+
89
+ describe("AuthorizationKeystore.read with intermediate projection facts (jinaga #204)", () => {
90
+ it("authorizes an administrator's direct query for an intermediate projection fact", async () => {
91
+ const setup = await givenAuthorization(/* subscriberIsAdmin */ true);
92
+
93
+ const eventRef = dehydrateFact(setup.event).find(f => f.type === Event.Type)!;
94
+
95
+ // Should resolve (not throw Forbidden) and return the seeded finalist.
96
+ const results = await setup.authorization.read(
97
+ subscriberIdentity, [eventRef], finalistsOfEvent);
98
+
99
+ expect(results).toHaveLength(1);
100
+ });
101
+
102
+ it("still denies a non-administrator's direct query for the same fact", async () => {
103
+ const setup = await givenAuthorization(/* subscriberIsAdmin */ false);
104
+
105
+ const eventRef = dehydrateFact(setup.event).find(f => f.type === Event.Type)!;
106
+
107
+ // Assert the specific authorization denial, not just any error, so a
108
+ // setup/engine failure can't masquerade as a passing regression guard.
109
+ const read = setup.authorization.read(subscriberIdentity, [eventRef], finalistsOfEvent);
110
+ await expect(read).rejects.toThrow(Forbidden);
111
+ await expect(read).rejects.toThrow(/Cannot distribute/);
112
+ });
113
+ });
114
+
115
+ async function givenAuthorization(subscriberIsAdmin: boolean) {
116
+ const storage = new MemoryStore();
117
+ const keystore = new MemoryKeystore();
118
+
119
+ const subscriberFact = await keystore.getOrCreateUserFact(subscriberIdentity);
120
+ const creatorFact = await keystore.getOrCreateUserFact(creatorIdentity);
121
+ const subscriber = new User(subscriberFact.fields.publicKey);
122
+ const creator = new User(creatorFact.fields.publicKey);
123
+
124
+ const tenant = new Tenant(creator);
125
+ const event = new Event(tenant, "event-id");
126
+ const competitor = new Competitor(tenant);
127
+ const finalist = new Finalist(competitor, event);
128
+
129
+ const facts = [
130
+ ...dehydrateFact(subscriber),
131
+ ...dehydrateFact(creator),
132
+ ...dehydrateFact(event),
133
+ ...dehydrateFact(competitor),
134
+ ...dehydrateFact(finalist)
135
+ ];
136
+ if (subscriberIsAdmin) {
137
+ const administrator = new Administrator(tenant, subscriber, new Date("2024-01-01"));
138
+ facts.push(...dehydrateFact(administrator));
139
+ }
140
+
141
+ // Dedupe by hash+type to avoid double-saving shared predecessors.
142
+ const seenKeys = new Set<string>();
143
+ const uniqueFacts = facts.filter(f => {
144
+ const k = `${f.type}|${f.hash}`;
145
+ if (seenKeys.has(k)) return false;
146
+ seenKeys.add(k);
147
+ return true;
148
+ });
149
+ await storage.save(uniqueFacts.map(f => ({ fact: f, signatures: [] })));
150
+
151
+ const fork = new PassThroughFork(storage);
152
+ const observable = new ObservableSource(storage);
153
+ const network = new NetworkNoOp();
154
+ const factManager = new FactManager(fork, observable, storage, network, []);
155
+
156
+ const authorizationRules = new AuthorizationRules(model)
157
+ .no(User)
158
+ .any(Tenant)
159
+ .any(Administrator)
160
+ .any(Event)
161
+ .any(Competitor)
162
+ .any(Finalist)
163
+ .any(CompetitorName);
164
+
165
+ const distributionRules = new DistributionRules([])
166
+ .share(model.given(Event).select((event, facts) => ({
167
+ // Finalist is only a traversal step toward the competitor projection.
168
+ finalists: facts.ofType(Finalist)
169
+ .join(finalist => finalist.event, event)
170
+ .selectMany(finalist => finalist.competitor.predecessor()
171
+ .select(competitor => ({
172
+ competitorNames: facts.ofType(CompetitorName)
173
+ .join(name => name.competitor, competitor)
174
+ .notExists(name => facts.ofType(CompetitorName)
175
+ .join(next => next.prior, name)
176
+ )
177
+ }))
178
+ )
179
+ })))
180
+ .with(model.given(Event).match((event, facts) =>
181
+ facts.ofType(Administrator)
182
+ .join(a => a.tenant, event.tenant)
183
+ .selectMany(a => facts.ofType(User).join(u => u, a.user))
184
+ ));
185
+
186
+ const authorization = new AuthorizationKeystore(
187
+ factManager, storage, keystore, authorizationRules, distributionRules);
188
+ return { authorization, event };
189
+ }
@@ -0,0 +1,220 @@
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
+ // Pass-through authorized: no engine denial is carried.
72
+ expect(result.denial).toBeUndefined();
73
+ });
74
+
75
+ it("returns intersected branches when the user is not yet authorized", async () => {
76
+ const setup = await givenAuthorizationWithDistribution(true, async (subscriber, creator) => {
77
+ const company = new Company(creator, "Acme");
78
+ // No Administrator yet — subscriber is not authorized.
79
+ return { company };
80
+ });
81
+
82
+ const officeSpec = model.given(Company).match((c, facts) =>
83
+ facts.ofType(Office).join(o => o.company, c)
84
+ ).specification;
85
+ const companyRef = dehydrateFact(setup.seeded.company)
86
+ .find(f => f.type === Company.Type)!;
87
+ const namedStart: ReferencesByName = { [officeSpec.given[0].label.name]: companyRef };
88
+
89
+ const result = await setup.authorization.verifyDistributionOrIntersect(
90
+ subscriberIdentity, officeSpec, namedStart);
91
+
92
+ // Intersection produced an alternate branch that lifts the rule's
93
+ // user-spec into the subscription. The intersected spec has an
94
+ // additional synthetic given and a different shape than the original.
95
+ expect(result.type).toBe("success");
96
+ if (result.type !== "success") return;
97
+ expect(result.branches.length).toBeGreaterThanOrEqual(1);
98
+ expect(result.branches[0].specification).not.toBe(officeSpec);
99
+ expect(result.branches[0].specification.given.length).toBe(2);
100
+ // The synthetic given is bound to the subscriber's user fact.
101
+ expect(result.branches[0].start[1]).toEqual({
102
+ type: setup.subscriberFact.type,
103
+ hash: setup.subscriberFact.hash
104
+ });
105
+ // Authorized-via-intersection (reactive) carries the engine failure
106
+ // that triggered it: the authenticated subscriber is excluded until
107
+ // the authorizing Administrator fact arrives.
108
+ expect(result.denial).toBeDefined();
109
+ expect(result.denial?.code).toBe("principal-excluded");
110
+ expect(result.denial?.reason).toBeTruthy();
111
+ });
112
+
113
+ it("returns denied when no distribution rule applies to the spec", async () => {
114
+ const setup = await givenAuthorizationWithDistribution(true, async (_subscriber, creator) => {
115
+ const company = new Company(creator, "Acme");
116
+ return { company };
117
+ });
118
+
119
+ // A spec the rule doesn't cover at all (different fact type).
120
+ const unrelatedSpec = model.given(Company).match((c, facts) =>
121
+ facts.ofType(Administrator).join(a => a.company, c)
122
+ ).specification;
123
+ const companyRef = dehydrateFact(setup.seeded.company)
124
+ .find(f => f.type === Company.Type)!;
125
+ const namedStart: ReferencesByName = { [unrelatedSpec.given[0].label.name]: companyRef };
126
+
127
+ const result = await setup.authorization.verifyDistributionOrIntersect(
128
+ subscriberIdentity, unrelatedSpec, namedStart);
129
+
130
+ expect(result.type).toBe("denied");
131
+ if (result.type !== "denied") return;
132
+ expect(result.reason).toBeTruthy();
133
+ // No rule covers this spec at all.
134
+ expect(result.code).toBe("no-matching-rule");
135
+ });
136
+
137
+ it("returns a passthrough branch when there are no distribution rules at all", async () => {
138
+ const setup = await givenAuthorizationWithDistribution(false, async (_subscriber, creator) => {
139
+ const company = new Company(creator, "Acme");
140
+ return { company };
141
+ });
142
+
143
+ const officeSpec = model.given(Company).match((c, facts) =>
144
+ facts.ofType(Office).join(o => o.company, c)
145
+ ).specification;
146
+ const companyRef = dehydrateFact(setup.seeded.company)
147
+ .find(f => f.type === Company.Type)!;
148
+ const namedStart: ReferencesByName = { [officeSpec.given[0].label.name]: companyRef };
149
+
150
+ const result = await setup.authorization.verifyDistributionOrIntersect(
151
+ subscriberIdentity, officeSpec, namedStart);
152
+
153
+ expect(result.type).toBe("success");
154
+ if (result.type !== "success") return;
155
+ expect(result.branches).toHaveLength(1);
156
+ expect(result.branches[0].specification).toBe(officeSpec);
157
+ // No distribution engine, so nothing is denied — pure pass-through.
158
+ expect(result.denial).toBeUndefined();
159
+ });
160
+ });
161
+
162
+ async function givenAuthorizationWithDistribution<T extends Record<string, any>>(
163
+ withDistributionRules: boolean,
164
+ buildFacts: (subscriber: User, creator: User) => Promise<T>
165
+ ) {
166
+ const storage = new MemoryStore();
167
+ const keystore = new MemoryKeystore();
168
+
169
+ const subscriberFact = await keystore.getOrCreateUserFact(subscriberIdentity);
170
+ const creatorFact = await keystore.getOrCreateUserFact(creatorIdentity);
171
+ const subscriber = new User(subscriberFact.fields.publicKey);
172
+ const creator = new User(creatorFact.fields.publicKey);
173
+
174
+ const seeded = await buildFacts(subscriber, creator);
175
+ const facts = [
176
+ ...dehydrateFact(subscriber),
177
+ ...dehydrateFact(creator),
178
+ ...Object.values(seeded).flatMap(v => dehydrateFact(v))
179
+ ];
180
+ // Dedupe by hash+type to avoid double-saving the shared predecessors.
181
+ const seenKeys = new Set<string>();
182
+ const uniqueFacts = facts.filter(f => {
183
+ const k = `${f.type}|${f.hash}`;
184
+ if (seenKeys.has(k)) return false;
185
+ seenKeys.add(k);
186
+ return true;
187
+ });
188
+ await storage.save(uniqueFacts.map(f => ({ fact: f, signatures: [] })));
189
+
190
+ const fork = new PassThroughFork(storage);
191
+ const observable = new ObservableSource(storage);
192
+ const network = new NetworkNoOp();
193
+ const factManager = new FactManager(fork, observable, storage, network, []);
194
+
195
+ const authorizationRules = new AuthorizationRules(model)
196
+ .no(User)
197
+ .any(Company)
198
+ .any(Office)
199
+ .any(Administrator);
200
+
201
+ const distributionRules = withDistributionRules
202
+ ? new DistributionRules([])
203
+ .share(model.given(Company).match((c, facts) =>
204
+ facts.ofType(Office).join(o => o.company, c)
205
+ ))
206
+ .with(model.given(Company).match((c, facts) =>
207
+ facts.ofType(Administrator)
208
+ .join(a => a.company, c)
209
+ .selectMany(a => facts.ofType(User).join(u => u, a.user))
210
+ ))
211
+ : null;
212
+
213
+ const authorization = new AuthorizationKeystore(
214
+ factManager, storage, keystore, authorizationRules, distributionRules);
215
+ return {
216
+ authorization,
217
+ subscriberFact,
218
+ seeded: seeded as T & { company: Company }
219
+ };
220
+ }