jinaga-server 3.6.0 → 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.
@@ -8,13 +8,16 @@ import {
8
8
  FeedResponse,
9
9
  MemoryStore,
10
10
  NetworkNoOp,
11
+ NoOpTracer,
11
12
  ObservableSource,
12
13
  PassThroughFork,
14
+ Trace,
15
+ Tracer,
13
16
  User
14
17
  } from "jinaga";
15
18
 
16
19
  import { AuthorizationKeystore } from "../../src/authorization/authorization-keystore";
17
- import { HttpRouter, RequestUser } from "../../src/http/router";
20
+ import { FeedNotFound, HttpRouter, RequestUser } from "../../src/http/router";
18
21
  import { Stream } from "../../src/http/stream";
19
22
  import { MemoryKeystore } from "../../src/memory/memory-keystore";
20
23
 
@@ -99,6 +102,25 @@ async function drainMicrotasks() {
99
102
  }
100
103
  }
101
104
 
105
+ // Records Trace.metric calls so tests can assert the structured distribution
106
+ // signal (issue #168 S3) without grepping logs. Everything else is a no-op,
107
+ // and dependency() still runs its operation so feeds() works unchanged.
108
+ class CapturingTracer extends NoOpTracer implements Tracer {
109
+ public readonly metrics: { message: string; measurements: { [key: string]: number } }[] = [];
110
+ metric(message: string, measurements: { [key: string]: number }): void {
111
+ this.metrics.push({ message, measurements });
112
+ }
113
+ }
114
+
115
+ async function withTracer<T>(tracer: Tracer, action: () => Promise<T>): Promise<T> {
116
+ Trace.configure(tracer);
117
+ try {
118
+ return await action();
119
+ } finally {
120
+ Trace.configure(new NoOpTracer());
121
+ }
122
+ }
123
+
102
124
  describe("late-auth subscription recovery", () => {
103
125
  it("subscribes empty when not authorized and activates when the auth fact arrives", async () => {
104
126
  const h = await makeHarness();
@@ -125,6 +147,19 @@ describe("late-auth subscription recovery", () => {
125
147
  expect(feedsResponse.feeds.length).toBeGreaterThan(0);
126
148
  const feedHash: string = feedsResponse.feeds[0];
127
149
 
150
+ // S1: the response reports a per-feed decision. This is the reactive
151
+ // (authorized-via-intersection) case — denied for the subscriber now,
152
+ // self-heals when the Administrator fact arrives — reported without
153
+ // any change to registration or keep-alive behavior.
154
+ expect(feedsResponse.decisions).toHaveLength(feedsResponse.feeds.length);
155
+ expect(feedsResponse.decisions.map((d: any) => d.feed).sort())
156
+ .toEqual([...feedsResponse.feeds].sort());
157
+ for (const d of feedsResponse.decisions) {
158
+ expect(d.decision).toBe("reactive");
159
+ expect(d.code).toBe("principal-excluded");
160
+ expect(d.reason).toBeTruthy();
161
+ }
162
+
128
163
  const stream: Stream<FeedResponse> = await (h.router as any).streamFeed(
129
164
  h.requestUser, { hash: feedHash }, {});
130
165
  expect(stream).not.toBeNull();
@@ -153,6 +188,38 @@ describe("late-auth subscription recovery", () => {
153
188
  .toContain(dehydrateFact(existingOffice).find(f => f.type === Office.Type)!.hash);
154
189
  });
155
190
 
191
+ it("reports an authorized pass-through decision when the user is already permitted", async () => {
192
+ const h = await makeHarness();
193
+ const company = new Company(h.creator, "Acme");
194
+ const companyRef = dehydrateFact(company).find(f => f.type === Company.Type)!;
195
+ await h.factManager.save(dehydrateFact(company).map(f => ({ fact: f, signatures: [] })));
196
+
197
+ // Grant the subscriber the Administrator role *before* subscribing, so
198
+ // the distribution rule authorizes the spec as-is — pass-through, no
199
+ // intersection.
200
+ const admin = new Administrator(company, h.subscriber, new Date("2026-05-27"));
201
+ await h.factManager.save(dehydrateFact(admin).map(f => ({ fact: f, signatures: [] })));
202
+
203
+ const input =
204
+ `let p1: ${Company.Type} = #${companyRef.hash}\n` +
205
+ `(p1: ${Company.Type}) {\n` +
206
+ ` o: ${Office.Type} [\n` +
207
+ ` o->company: ${Company.Type} = p1\n` +
208
+ ` ]\n` +
209
+ `} => o`;
210
+
211
+ const feedsResponse = await (h.router as any).feeds(h.requestUser, input);
212
+ expect(feedsResponse.feeds.length).toBeGreaterThan(0);
213
+
214
+ // S1: authorized decisions carry no denial code.
215
+ expect(feedsResponse.decisions).toHaveLength(feedsResponse.feeds.length);
216
+ for (const d of feedsResponse.decisions) {
217
+ expect(d.decision).toBe("authorized");
218
+ expect(d.code).toBeUndefined();
219
+ expect(d.reason).toBeTruthy();
220
+ }
221
+ });
222
+
156
223
  it("does not let another authenticated user reuse an intersected feed hash", async () => {
157
224
  const h = await makeHarness();
158
225
  const company = new Company(h.creator, "Acme");
@@ -236,10 +303,133 @@ describe("late-auth subscription recovery", () => {
236
303
  const feedsResponse = await (h.router as any).feeds(h.requestUser, input);
237
304
  expect(feedsResponse.feeds.length).toBeGreaterThan(0);
238
305
 
306
+ // S1: no rule covers this spec, so the decision is `denied` with the
307
+ // structured `no-matching-rule` code. The feed is still registered and
308
+ // served (empty), exactly as before — this only reports the decision.
309
+ expect(feedsResponse.decisions).toHaveLength(feedsResponse.feeds.length);
310
+ for (const d of feedsResponse.decisions) {
311
+ expect(d.decision).toBe("denied");
312
+ expect(d.code).toBe("no-matching-rule");
313
+ expect(d.reason).toBeTruthy();
314
+ }
315
+
239
316
  // Polling the cached feed must also stay live (empty page, not 403).
240
317
  const feedHash: string = feedsResponse.feeds[0];
241
318
  const page = await (h.router as any).feed(h.requestUser, { hash: feedHash }, {});
242
319
  expect(page).not.toBeNull();
243
320
  expect(page.references).toEqual([]);
244
321
  });
322
+
323
+ it("S2: recomputes the per-user decision on a repeated POST /feeds", async () => {
324
+ const h = await makeHarness();
325
+ const company = new Company(h.creator, "Acme");
326
+ const companyRef = dehydrateFact(company).find(f => f.type === Company.Type)!;
327
+ const office = new Office(company, "HQ");
328
+ await h.factManager.save(dehydrateFact(office).map(f => ({ fact: f, signatures: [] })));
329
+
330
+ const input =
331
+ `let p1: ${Company.Type} = #${companyRef.hash}\n` +
332
+ `(p1: ${Company.Type}) {\n` +
333
+ ` o: ${Office.Type} [\n` +
334
+ ` o->company: ${Company.Type} = p1\n` +
335
+ ` ]\n` +
336
+ `} => o`;
337
+
338
+ // First registration: subscriber is not yet an Administrator, so the
339
+ // decision is reactive (authorized via intersection).
340
+ const first = await (h.router as any).feeds(h.requestUser, input);
341
+ expect(first.decisions.every((d: any) => d.decision === "reactive")).toBe(true);
342
+
343
+ // The authorizing fact arrives.
344
+ const admin = new Administrator(company, h.subscriber, new Date("2026-05-27"));
345
+ await h.factManager.save(dehydrateFact(admin).map(f => ({ fact: f, signatures: [] })));
346
+
347
+ // A repeated POST /feeds for the same spec now returns `authorized` —
348
+ // the decision self-healed rather than returning a stale cached value.
349
+ const second = await (h.router as any).feeds(h.requestUser, input);
350
+ expect(second.decisions.length).toBeGreaterThan(0);
351
+ for (const d of second.decisions) {
352
+ expect(d.decision).toBe("authorized");
353
+ expect(d.code).toBeUndefined();
354
+ }
355
+ });
356
+
357
+ it("S2: returns different per-user decisions for the same spec", async () => {
358
+ const h = await makeHarness();
359
+ const company = new Company(h.creator, "Acme");
360
+ const companyRef = dehydrateFact(company).find(f => f.type === Company.Type)!;
361
+ await h.factManager.save(dehydrateFact(company).map(f => ({ fact: f, signatures: [] })));
362
+
363
+ // Authorize the subscriber but not Mallory.
364
+ const admin = new Administrator(company, h.subscriber, new Date("2026-05-27"));
365
+ await h.factManager.save(dehydrateFact(admin).map(f => ({ fact: f, signatures: [] })));
366
+
367
+ const input =
368
+ `let p1: ${Company.Type} = #${companyRef.hash}\n` +
369
+ `(p1: ${Company.Type}) {\n` +
370
+ ` o: ${Office.Type} [\n` +
371
+ ` o->company: ${Company.Type} = p1\n` +
372
+ ` ]\n` +
373
+ `} => o`;
374
+
375
+ const subscriberResponse = await (h.router as any).feeds(h.requestUser, input);
376
+ expect(subscriberResponse.decisions.every((d: any) => d.decision === "authorized")).toBe(true);
377
+
378
+ // Mallory is a different authenticated user with no Administrator fact.
379
+ const malloryRequest: RequestUser = {
380
+ provider: "mock", id: "mallory", profile: {} as any
381
+ };
382
+ await (h.router as any).login(malloryRequest);
383
+ const malloryResponse = await (h.router as any).feeds(malloryRequest, input);
384
+
385
+ // Same spec, different user: the decision is computed for Mallory, not
386
+ // reused from the subscriber. Mallory is not authorized, so the spec is
387
+ // rewritten via intersection into a reactive, user-specific feed.
388
+ expect(malloryResponse.decisions.every((d: any) => d.decision === "reactive")).toBe(true);
389
+ // The reactive rewrite yields a distinct, owner-bound hash — never the
390
+ // subscriber's authorized pass-through hash.
391
+ expect(malloryResponse.feeds).not.toEqual(subscriberResponse.feeds);
392
+ });
393
+
394
+ it("S3: emits a structured distribution.unmatched metric tagged by code on denial", async () => {
395
+ const h = await makeHarness();
396
+ const company = new Company(h.creator, "Acme");
397
+ const companyRef = dehydrateFact(company).find(f => f.type === Company.Type)!;
398
+ await h.factManager.save(dehydrateFact(company).map(f => ({ fact: f, signatures: [] })));
399
+
400
+ // A spec no distribution rule covers → denied · no-matching-rule.
401
+ const input =
402
+ `let p1: ${Company.Type} = #${companyRef.hash}\n` +
403
+ `(p1: ${Company.Type}) {\n` +
404
+ ` a: ${Administrator.Type} [\n` +
405
+ ` a->company: ${Company.Type} = p1\n` +
406
+ ` ]\n` +
407
+ `} => a`;
408
+
409
+ const tracer = new CapturingTracer();
410
+ await withTracer(tracer, () => (h.router as any).feeds(h.requestUser, input));
411
+
412
+ const unmatched = tracer.metrics.filter(m => m.message === "distribution.unmatched");
413
+ expect(unmatched).toHaveLength(1);
414
+ // Stable metric name, denial code as the (bounded) measurement key.
415
+ expect(unmatched[0].measurements).toEqual({ "no-matching-rule": 1 });
416
+ });
417
+
418
+ it("S4: throws FeedNotFound for an unknown feed hash, but not for a missing hash", async () => {
419
+ const h = await makeHarness();
420
+
421
+ // A known route with an unknown/expired hash is a distinct condition
422
+ // from a route miss: the method throws FeedNotFound (handleError maps
423
+ // it to a 404 with a `feed_not_found` body), rather than returning the
424
+ // generic null → "Not Found".
425
+ await expect((h.router as any).feed(h.requestUser, { hash: "deadbeef" }, {}))
426
+ .rejects.toBeInstanceOf(FeedNotFound);
427
+ await expect((h.router as any).streamFeed(h.requestUser, { hash: "deadbeef" }, {}))
428
+ .rejects.toBeInstanceOf(FeedNotFound);
429
+
430
+ // A missing hash param (wrong URL) still collapses to the generic 404
431
+ // path — the method returns null, unchanged.
432
+ const missing = await (h.router as any).feed(h.requestUser, {}, {});
433
+ expect(missing).toBeNull();
434
+ });
245
435
  });
@@ -102,7 +102,9 @@ describe("HttpRouter streamFeed anchor handling", () => {
102
102
  stream.next(r => { received.push(r); });
103
103
 
104
104
  await waitForMicrotasks();
105
- expect(received).toEqual([]);
105
+ // The session catches up immediately (no tuples, empty waitlist) and
106
+ // emits a SYNC frame with empty references. No post has arrived yet.
107
+ expect(received.flatMap(r => r.references)).toEqual([]);
106
108
 
107
109
  // Save the author + a post that joins to it.
108
110
  const post = new Post(h.author, "first");