@aiquants/daily-report 0.13.0 → 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 +3 -0
- package/dist/server.d.mts +91 -30
- package/dist/server.d.ts +91 -30
- package/dist/server.js +8 -8
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +8 -8
- package/dist/server.mjs.map +1 -1
- package/dist/styles/daily-report.standalone.css +1 -1
- package/package.json +7 -5
- package/src/server/handlers.ids-stream.spec.ts +4 -2
- package/src/server/handlers.ts +30 -26
- package/src/server/handlers.visibility.spec.ts +70 -0
- package/src/server/service.spec.ts +90 -61
- package/src/server/service.ts +57 -52
- package/src/server/test-helpers/handlers-config.ts +19 -19
- package/src/server/visibility.spec.ts +129 -3
- package/src/server/visibility.ts +106 -11
package/src/server/visibility.ts
CHANGED
|
@@ -154,14 +154,102 @@ export function buildVisibleSourceTypeCondition(sourceTypeColumn: AnyMsSqlColumn
|
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
/**
|
|
157
|
-
*
|
|
158
|
-
*
|
|
157
|
+
* Request-scoped viewer scope carrying the normalized visible source-type set.
|
|
158
|
+
* 正規化済みの可視 sourceType 集合を運ぶ、リクエストスコープの閲覧範囲。
|
|
159
159
|
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
* (
|
|
163
|
-
*
|
|
164
|
-
*
|
|
160
|
+
* このクラスは private フィールドを持つことで**名目型**になっており、`{ visible: null }` のような
|
|
161
|
+
* 構造的偽造も、`{ ...scope, visible: null }` のようなスプレッドによる widening も型が拒否する
|
|
162
|
+
* (`unique symbol` ブランドでは後者を防げないことを実測で確認済み)。
|
|
163
|
+
*
|
|
164
|
+
* 生成経路は {@link DailyReportViewerScope.restrictTo} と {@link DailyReportViewerScope.unrestricted}
|
|
165
|
+
* の 2 つだけであり、`null` を「可視性」としてそのまま綴ることはできない。結果として、
|
|
166
|
+
* **絞り込まれない読み取りは必ず `unrestricted(理由)` として現れる**ため、
|
|
167
|
+
* `grep -rn "DailyReportViewerScope.unrestricted("` が fail-open の全数リストになる。
|
|
168
|
+
*/
|
|
169
|
+
export class DailyReportViewerScope {
|
|
170
|
+
/**
|
|
171
|
+
* 正規化済みの可視集合を保持する private スロット。
|
|
172
|
+
*
|
|
173
|
+
* ❗ public な読み取り専用プロパティにしてはならない。`readonly` は型のみの制約であり、
|
|
174
|
+
* `Object.assign(scope, { visible: null })` は型検査も実行時も素通りして実体を書き換える
|
|
175
|
+
* (実測済み)。private スロット + getter なら代入が実行時にも失敗する。
|
|
176
|
+
*/
|
|
177
|
+
readonly #visible: VisibleSourceTypeSet
|
|
178
|
+
|
|
179
|
+
private constructor(visible: VisibleSourceTypeSet) {
|
|
180
|
+
this.#visible = visible
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 正規化済みの可視集合。`null` は無制限、空集合は 0 件 (fail-close)。 */
|
|
184
|
+
get visible(): VisibleSourceTypeSet {
|
|
185
|
+
// ❗ 実体をそのまま返さない。`ReadonlySet` は型のみの制約で、実体の `Set` は
|
|
186
|
+
// `(scope.visible as unknown as Set<string>).add(...)` で in-place に広げられる (実測済み)。
|
|
187
|
+
// 毎回コピーを返すことで、外部から可視集合を広げる経路を断つ
|
|
188
|
+
return this.#visible === null ? null : new Set(this.#visible)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Builds a scope restricted to the given source types (empty input = zero rows).
|
|
193
|
+
* 指定した sourceType へ限定した閲覧範囲を生成する処理 (空入力は 0 件)。
|
|
194
|
+
*
|
|
195
|
+
* 入力は {@link normalizeVisibleSourceTypes} を通るため、大小文字・前後空白・重複・非文字列は
|
|
196
|
+
* ここで吸収される。
|
|
197
|
+
*
|
|
198
|
+
* ❗ 引数は `readonly string[]` に**狭めてある**。`null` / `undefined` を受け付けると、
|
|
199
|
+
* 「restrict」という名前のまま無制限スコープを生む第 2 の入口になり、
|
|
200
|
+
* 「絞り込まれない読み取りは必ず {@link DailyReportViewerScope.unrestricted} に現れる」
|
|
201
|
+
* という監査不変条件が崩れる。無制限は必ず理由付きで明示すること。
|
|
202
|
+
*
|
|
203
|
+
* @param sourceTypes Visible source-type tokens. 可視 sourceType のトークン群。
|
|
204
|
+
* @returns The scope. 生成した閲覧範囲。
|
|
205
|
+
*/
|
|
206
|
+
static restrictTo(sourceTypes: readonly string[]): DailyReportViewerScope {
|
|
207
|
+
return new DailyReportViewerScope(normalizeVisibleSourceTypes(sourceTypes))
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Builds an unrestricted scope; every call site is an audited fail-open.
|
|
212
|
+
* 無制限の閲覧範囲を生成する処理。全呼び出し箇所が監査対象の fail-open となる。
|
|
213
|
+
*
|
|
214
|
+
* ❗ 理由文字列は必須。読み捨てるが、呼び出し箇所の全数列挙と「なぜここは絞らなくてよいか」の
|
|
215
|
+
* 記録を強制するために受け取る。
|
|
216
|
+
*
|
|
217
|
+
* @param auditReason Why unrestricted access is correct here. なぜここで無制限が正当かの理由。
|
|
218
|
+
* @returns The unrestricted scope. 無制限の閲覧範囲。
|
|
219
|
+
*/
|
|
220
|
+
static unrestricted(auditReason: string): DailyReportViewerScope {
|
|
221
|
+
void auditReason
|
|
222
|
+
return new DailyReportViewerScope(null)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Computes the cache-key digest for a whole viewer scope.
|
|
228
|
+
* 閲覧範囲全体からキャッシュキー用ダイジェストを算出する処理。
|
|
229
|
+
*
|
|
230
|
+
* スコープに次元が増えてもキャッシュキーの生成点を 1 つに保つための入口。
|
|
231
|
+
*
|
|
232
|
+
* @param scope Viewer scope. 閲覧範囲。
|
|
233
|
+
* @returns Digest usable as a cache-key component. キャッシュキー構成要素として使えるダイジェスト。
|
|
234
|
+
*/
|
|
235
|
+
export function computeViewerScopeDigest(scope: DailyReportViewerScope): string {
|
|
236
|
+
return computeSourceTypeDigest(scope.visible)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Computes a stable and bounded cache-key digest of the set.
|
|
241
|
+
* 可視集合から安定・有界なキャッシュキー用ダイジェストを算出する処理。
|
|
242
|
+
*
|
|
243
|
+
* 無制限は `"all"`、空集合は `"none"`、それ以外は `s:` 名前空間の下へ**長さ接頭辞付きトークンを昇順連結**する。
|
|
244
|
+
* 連結は長さ接頭辞付きなので**単射**であり、通常経路 (`s:`) では論理的に等価な集合が必ず同一の、
|
|
245
|
+
* 異なる集合が必ず別のダイジェストになる。sentinel (`all` / `none`) とも名前空間が分かれるため、
|
|
246
|
+
* 限定集合が無制限 / 全拒否と同じバケットを共有することはない。権限が安定している間はキーが
|
|
247
|
+
* 不変になるため、キャッシュは完全に再利用される (性能無退行)。
|
|
248
|
+
*
|
|
249
|
+
* ❗ 区分数が異常に多い場合だけキー長を抑えるため FNV-1a 32bit へ退避し `sh:` 接頭辞を付ける。
|
|
250
|
+
* **この退避経路に限り理論上の衝突がありうる** (32bit)。衝突しても影響は同一ユーザーの
|
|
251
|
+
* キャッシュ再利用に留まり、認可判定そのものは SQL 述語が担うため漏洩には至らない。
|
|
252
|
+
* 通常の区分数 (数個) では到達しない。
|
|
165
253
|
*
|
|
166
254
|
* @param set Normalized visible set. 正規化済みの可視集合。
|
|
167
255
|
* @returns Digest usable as a cache-key component. キャッシュキー構成要素として使えるダイジェスト。
|
|
@@ -173,10 +261,17 @@ export function computeSourceTypeDigest(set: VisibleSourceTypeSet): string {
|
|
|
173
261
|
if (set.size === 0) {
|
|
174
262
|
return "none"
|
|
175
263
|
}
|
|
176
|
-
// 昇順ソートで要素順に依存しないキーにする (同じ集合は常に同じダイジェスト)
|
|
177
|
-
|
|
264
|
+
// 昇順ソートで要素順に依存しないキーにする (同じ集合は常に同じダイジェスト)。
|
|
265
|
+
// ❗ 各トークンは長さ接頭辞付きで連結する。素の区切り文字だけでは、区切り文字を含む
|
|
266
|
+
// トークンが別の集合と同じ連結結果になりうる (単射でなくなる)
|
|
267
|
+
const joined = [...set]
|
|
268
|
+
.sort()
|
|
269
|
+
.map((token) => `${token.length}:${token}`)
|
|
270
|
+
.join(DIGEST_SEPARATOR)
|
|
271
|
+
// ❗ 限定集合の名前空間を `s:` で分ける。分けないと、`"all"` や `"none"` へ畳み込まれる
|
|
272
|
+
// トークンだけを許された閲覧者が、無制限 / 全拒否と同じキャッシュバケットを共有してしまう
|
|
178
273
|
if (joined.length <= DIGEST_RAW_MAX_LENGTH) {
|
|
179
|
-
return joined
|
|
274
|
+
return `s:${joined}`
|
|
180
275
|
}
|
|
181
276
|
// 長大キーの退避のみハッシュ化する。衝突しても影響は同一ユーザーのキャッシュ再利用に限られ、
|
|
182
277
|
// かつ TTL で失効するが、生連結と混同しないよう接頭辞で区別する
|
|
@@ -185,5 +280,5 @@ export function computeSourceTypeDigest(set: VisibleSourceTypeSet): string {
|
|
|
185
280
|
hash ^= joined.charCodeAt(i)
|
|
186
281
|
hash = Math.imul(hash, 0x01000193)
|
|
187
282
|
}
|
|
188
|
-
return `
|
|
283
|
+
return `sh:${(hash >>> 0).toString(16)}`
|
|
189
284
|
}
|