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.
- package/dist/authorization/authorization-keystore.d.ts +34 -2
- package/dist/authorization/authorization-keystore.js +59 -4
- package/dist/authorization/authorization-keystore.js.map +1 -1
- package/dist/feeds/feed-stream-session.d.ts +84 -0
- package/dist/feeds/feed-stream-session.js +331 -0
- package/dist/feeds/feed-stream-session.js.map +1 -0
- package/dist/http/router.d.ts +10 -3
- package/dist/http/router.js +186 -184
- package/dist/http/router.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/jinaga-server.d.ts +7 -0
- package/dist/jinaga-server.js +19 -2
- 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/design/feeds-persistent-proxy-plan.md +11 -1
- 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 +106 -5
- package/src/feeds/feed-stream-session.ts +386 -0
- package/src/http/router.ts +212 -237
- package/src/index.ts +2 -1
- package/src/jinaga-server.ts +45 -5
- 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/intermediateProjectionFeedSpec.ts +189 -0
- package/test/authorization/subscriptionIntersectionSpec.ts +220 -0
- package/test/feeds/feedStreamSessionRegressionSpec.ts +227 -0
- package/test/feeds/feedStreamSessionSpec.ts +351 -0
- package/test/http/anchorListenerSpec.ts +117 -0
- package/test/http/feedStreamSessionIntegrationSpec.ts +202 -0
- package/test/http/lateAuthRecoverySpec.ts +435 -0
- package/test/http/streamFeedAnchorSpec.ts +171 -0
package/src/jinaga-server.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
235
|
+
return new AuthorizationNoOpWithSubscriptions(factManager, store);
|
|
196
236
|
}
|
|
197
237
|
}
|
|
198
238
|
|
|
@@ -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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
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
|
+
}
|