@aiquants/daily-report 0.12.4 → 0.14.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/README.md CHANGED
@@ -119,9 +119,44 @@ export const loader = (args) => dailyReportServer.sse.loader(args)
119
119
  - `redis` — `getClient()` (get/incr/xAdd/xRange/xRevRange) + `createClient()` (blocking xRead). Structurally matches a `node-redis` v5 client. If omitted, SSE publishing is skipped (with a warning) and epoch is disabled.
120
120
  - `externalSources[]` — When `Hub.source_type` matches, performs a `LEFT JOIN` on `Hub.source_id_num = idColumn` and converts fields via `mapRow`.
121
121
  - `draftLabelName` / `draftLabelNames` — Database label names representing draft states (single string or array of candidates like `["Draft", "Work in Progress"]`). Used for server-side cross-user visibility filtering (hiding drafts from other users) and `isDraft` evaluation.
122
+ - `resolveVisibleSourceTypes(request)` — **Optional row-level authorization port.** Returns the `Hub.source_type` values this request may view. Applies uniformly to every server data path: list (ids stream), business-date list, detail, comments, attachment bytes, and SSE. See [Source-type visibility](#source-type-visibility) below.
122
123
  - `enableDevCacheClear` — Gates the dev-only `POST /action` `intent=clearCache` (flush every worker's cache). Default `false` → the handler returns `400` before touching the service. Wire `import.meta.env.DEV` to enable it only in development (any authenticated user could otherwise flush all caches without limit).
123
124
  - Primary tuning parameters: `idsTtlMs` (180s) / `businessDateTtlMs` (300s) / `streamKey` / `streamMaxLen` / `loginRedirectPath`.
124
125
 
126
+ ### Source-type visibility
127
+
128
+ Restrict which report categories a viewer may see, without the package depending on any authorization library. The port receives the request and returns plain strings; your app decides the policy.
129
+
130
+ ```ts
131
+ createDailyReportServer({
132
+ // …existing config
133
+ resolveVisibleSourceTypes: async (request, { reason }) => {
134
+ // `reason === "refresh"` is the periodic re-check of a live SSE connection: bypass any
135
+ // request-scoped cache there, or a revoked grant keeps streaming until the client reconnects.
136
+ const grants = await yourAuthz.grantsFor(request, { skipCache: reason === "refresh" })
137
+ return grants.canReadLegacy ? ["legacy"] : []
138
+ },
139
+ })
140
+ ```
141
+
142
+ **Contract**
143
+
144
+ | Return value | Meaning |
145
+ | --- | --- |
146
+ | `undefined` / `null` | Unrestricted — every source type (the default when the port is not injected). |
147
+ | `["legacy", "Internal"]` | Only those source types are visible. |
148
+ | `[]` | Nothing is visible (zero rows) — **not** the same as `null`. |
149
+ | throws | Treated as `[]` (deny all). The failure is logged; an authorization-store outage never falls open. |
150
+
151
+ - **Vocabulary**: the strings are `DailyReportHub.source_type` **values** — the `sourceType` of each entry you registered in `externalSources`, plus `"Internal"` which the package writes for reports it creates. They are *not* your authorization resource keys; mapping a resource key (e.g. `report_legacy`) onto a source type (e.g. `legacy`) is the consuming app's job. Read the canonical set at boot from `dailyReportServer.knownSourceTypes` and assert your mapping against it — an unknown token silently matches zero rows.
152
+ - **Matching** is case-insensitive on both the JS and SQL sides (both fold to lower case), so it does not depend on the database collation. Surrounding whitespace is trimmed from the *tokens you return* but never from the stored column value — the two sides fold identically, and a padded stored value is simply invisible (fail-closed) rather than visible on one path and hidden on the other.
153
+ - **Where it applies**: the SQL predicate is injected in the service layer, so it holds for every delivery path (including attachment bytes) rather than only the HTTP handlers. It also gates the mutations that act on someone else's report (`addComment`, `deleteComment`, star, read); a hidden report is reported as `404`, never `403`.
154
+ - **How it reaches the service**: the resolved set is wrapped in a `DailyReportViewerScope` and passed as the **first** argument of every gated service method.
155
+ The class is nominal (it holds a private field), so `{ visible: null }`, `{ ...scope, visible: null }` and `new DailyReportViewerScope(...)` are all type errors — the only ways to obtain one are `DailyReportViewerScope.restrictTo(tokens)` — which accepts `readonly string[]` only, so it cannot be handed a nullish value and quietly widen — and `DailyReportViewerScope.unrestricted(auditReason)`.
156
+ Because a bare `null` is not spellable as a visibility value, `grep -rn "DailyReportViewerScope.unrestricted("` enumerates every unfiltered read in a codebase, each carrying a written reason.
157
+ - **Performance**: the port is called once per request on the critical path (cache keys incorporate the resolved set), so keep it fast — cache grants per session/user with a short TTL (≤ 60s). A live SSE connection re-resolves on its existing keep-alive tick (~60s) to bound how long a revoked grant keeps streaming; that call passes `reason: "refresh"` and reuses the connection's original `Request`, so a cache keyed on request identity **must** be bypassed when `reason === "refresh"` or the re-check silently returns the stale set.
158
+ - **SSE latency**: because a live connection re-resolves periodically, a grant change (in either direction) takes effect for the stream within ~60s. Events dropped before a widening are not replayed; the next read (which re-resolves per request) restores them.
159
+
125
160
  ## Client wiring
126
161
 
127
162
  ```tsx
package/dist/client.d.mts CHANGED
@@ -2,7 +2,7 @@ import * as react from 'react';
2
2
  import { RefObject, ReactNode, Context } from 'react';
3
3
  import { ScrollBarThumbOverlayRenderProps, ScrollPaneInertiaOptions, ScrollBarTapCircleOptions } from '@aiquants/virtualscroll';
4
4
  import { D as DailyReportAttachmentSummary, a as DailyReportItem, b as DailyReportDetail, c as DailyReportUser } from './types-Ct1ggzy-.mjs';
5
- import { U as UIComment, D as DailyReportSseMessage } from './sse-schema-BiUnufrI.mjs';
5
+ import { U as UIComment, D as DailyReportSseMessage } from './sse-schema-lMo8yW4e.mjs';
6
6
  import { ShouldRevalidateFunction } from 'react-router';
7
7
  import 'zod';
8
8
 
package/dist/client.d.ts CHANGED
@@ -2,7 +2,7 @@ import * as react from 'react';
2
2
  import { RefObject, ReactNode, Context } from 'react';
3
3
  import { ScrollBarThumbOverlayRenderProps, ScrollPaneInertiaOptions, ScrollBarTapCircleOptions } from '@aiquants/virtualscroll';
4
4
  import { D as DailyReportAttachmentSummary, a as DailyReportItem, b as DailyReportDetail, c as DailyReportUser } from './types-Ct1ggzy-.js';
5
- import { U as UIComment, D as DailyReportSseMessage } from './sse-schema-YZh7Pz7i.js';
5
+ import { U as UIComment, D as DailyReportSseMessage } from './sse-schema-D1oZnFg6.js';
6
6
  import { ShouldRevalidateFunction } from 'react-router';
7
7
  import 'zod';
8
8