@quazardous/qdadm 2.19.0 → 2.21.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/package.json +2 -2
- package/src/components/lists/ListPage.vue +7 -0
- package/src/composables/index.ts +3 -0
- package/src/composables/useListPage.filters.ts +137 -67
- package/src/composables/useListPage.ts +106 -18
- package/src/composables/useListPage.types.ts +74 -5
- package/src/composables/useListPage.utils.ts +58 -0
- package/src/entity/EntityManager.interface.ts +11 -0
- package/src/entity/EntityManager.ts +5 -0
- package/src/entity/EntityManager.types.ts +11 -0
- package/src/entity/auth/SecurityChecker.ts +88 -2
- package/src/entity/auth/index.ts +2 -0
- package/src/entity/storage/ApiStorage.ts +16 -2
- package/src/entity/storage/LocalStorage.ts +1 -1
- package/src/entity/storage/MemoryStorage.ts +7 -2
- package/src/entity/storage/MockApiStorage.ts +1 -1
- package/src/entity/storage/SdkStorage.ts +12 -3
- package/src/index.ts +19 -0
- package/src/kernel/Kernel.auth.ts +4 -0
- package/src/kernel/Kernel.registries.ts +26 -7
- package/src/kernel/Kernel.types.ts +23 -1
- package/src/kernel/Kernel.vue.ts +3 -0
- package/src/kernel/SignalBus.ts +6 -0
- package/src/modules/debug/AuthCollector.ts +15 -1
- package/src/modules/debug/components/panels/AuthPanel.vue +1 -0
- package/src/plugin.ts +1 -1
- package/src/query/clientFilter.ts +14 -3
- package/src/routeState/CompositePersister.ts +144 -0
- package/src/routeState/CookiePersister.ts +109 -0
- package/src/routeState/MemoryPersister.ts +60 -0
- package/src/routeState/NullPersister.ts +33 -0
- package/src/routeState/RouteStatePersister.ts +86 -0
- package/src/routeState/UrlPersister.ts +207 -0
- package/src/routeState/WebStoragePersister.ts +149 -0
- package/src/routeState/defaultPersister.ts +66 -0
- package/src/routeState/factory.ts +86 -0
- package/src/routeState/index.ts +32 -0
- package/src/routeState/keyLocator.ts +84 -0
- package/src/security/RolesProvider.ts +8 -0
|
@@ -141,6 +141,15 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
141
141
|
)
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
// A `grant` that cannot judge would be silently skipped, leaving the role
|
|
145
|
+
// matrix to decide alone while the app believes its backend does (#2225).
|
|
146
|
+
if (security.grant && typeof security.grant.isGranted !== 'function') {
|
|
147
|
+
throw new Error(
|
|
148
|
+
'[qdadm] security.grant must provide isGranted(attribute, subject, user). ' +
|
|
149
|
+
'Without it the application judgement would be skipped and the role matrix would decide alone.'
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
144
153
|
let rolesProvider = security.rolesProvider
|
|
145
154
|
if (!rolesProvider && (security.role_permissions || security.role_hierarchy)) {
|
|
146
155
|
rolesProvider = new StaticRoleProvider({
|
|
@@ -152,6 +161,7 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
152
161
|
|
|
153
162
|
this.securityChecker = createSecurityChecker({
|
|
154
163
|
rolesProvider: rolesProvider || undefined,
|
|
164
|
+
grant: security.grant,
|
|
155
165
|
getCurrentUser: () =>
|
|
156
166
|
(entityAuthAdapter as EntityAuthAdapter | null)?.getCurrentUser?.() || null,
|
|
157
167
|
})
|
|
@@ -165,14 +175,22 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
165
175
|
adapter.setSecurityChecker(this.securityChecker)
|
|
166
176
|
}
|
|
167
177
|
|
|
178
|
+
// One context for both installers. `permissionRegistry` lets a provider or
|
|
179
|
+
// a judge pre-warm in one batch (#2225) — but modules register their
|
|
180
|
+
// entities AFTER this point, so the keys must be read at fetch time, not
|
|
181
|
+
// inside install().
|
|
182
|
+
const ctx = {
|
|
183
|
+
orchestrator: this.orchestrator,
|
|
184
|
+
signals: this.signals,
|
|
185
|
+
permissionRegistry: this.permissionRegistry,
|
|
186
|
+
}
|
|
168
187
|
if (rolesProvider?.install) {
|
|
169
|
-
const ctx = {
|
|
170
|
-
orchestrator: this.orchestrator,
|
|
171
|
-
signals: this.signals,
|
|
172
|
-
}
|
|
173
188
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
174
189
|
rolesProvider.install(ctx as any)
|
|
175
190
|
}
|
|
191
|
+
if (security.grant?.install) {
|
|
192
|
+
security.grant.install(ctx)
|
|
193
|
+
}
|
|
176
194
|
}
|
|
177
195
|
|
|
178
196
|
/**
|
|
@@ -361,8 +379,8 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
361
379
|
'features', 'hashMode', 'homeRoute', 'i18n', 'layouts', 'managerRegistry',
|
|
362
380
|
'managerResolver', 'managers', 'moduleDefs', 'modules', 'modulesOptions',
|
|
363
381
|
'notifications', 'onAuthExpired', 'pages', 'parentParamMode', 'primevue',
|
|
364
|
-
'root', 'routeParamResolver', 'routePrefix', '
|
|
365
|
-
'sse', 'storageResolver', 'toast', 'warmup',
|
|
382
|
+
'root', 'routeParamResolver', 'routePrefix', 'routeState', 'sectionOrder',
|
|
383
|
+
'security', 'sse', 'storageResolver', 'toast', 'warmup',
|
|
366
384
|
])
|
|
367
385
|
|
|
368
386
|
proto._validateKernelOptions = function (this: Self): void {
|
|
@@ -388,12 +406,13 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
388
406
|
this._warnUnknownKeys(
|
|
389
407
|
'security.',
|
|
390
408
|
security,
|
|
391
|
-
new Set(['role_hierarchy', 'role_permissions', 'role_labels', 'entity_permissions', 'rolesProvider']),
|
|
409
|
+
new Set(['role_hierarchy', 'role_permissions', 'role_labels', 'entity_permissions', 'rolesProvider', 'grant']),
|
|
392
410
|
{
|
|
393
411
|
role_hierarchy: 'roles will not inherit from one another',
|
|
394
412
|
role_permissions: 'no role will carry any permission',
|
|
395
413
|
entity_permissions: 'per-entity permissions will not be generated',
|
|
396
414
|
rolesProvider: "the current user's roles will not be resolved",
|
|
415
|
+
grant: "the application's judgement will not be consulted — the role matrix decides alone",
|
|
397
416
|
}
|
|
398
417
|
)
|
|
399
418
|
}
|
|
@@ -10,8 +10,10 @@ import type { RoutesConfig } from './EventRouter'
|
|
|
10
10
|
import type { EntityManager } from '../entity/EntityManager'
|
|
11
11
|
import type { EntityAuthAdapter } from '../entity/auth/EntityAuthAdapter'
|
|
12
12
|
import type { RoleProvider } from '../security/RolesProvider'
|
|
13
|
+
import type { GrantJudge } from '../entity/auth/SecurityChecker'
|
|
13
14
|
import type { I18nOptions } from '../i18n/types'
|
|
14
15
|
import type { ApiClientSource } from '../api/apiClient'
|
|
16
|
+
import type { RouteStatePersister } from '../routeState'
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
19
|
* Auth adapter interface (app-level authentication)
|
|
@@ -97,7 +99,12 @@ export interface SecurityConfig {
|
|
|
97
99
|
role_permissions?: Record<string, string[]>
|
|
98
100
|
role_labels?: Record<string, string>
|
|
99
101
|
entity_permissions?: boolean | string[]
|
|
100
|
-
rolesProvider?: RoleProvider
|
|
102
|
+
rolesProvider?: RoleProvider /**
|
|
103
|
+
* Application-provided judgement (#2225), consulted before the role matrix.
|
|
104
|
+
* `true`/`false` is the verdict, anything else falls through; a judge that
|
|
105
|
+
* throws denies. See docs/security.md.
|
|
106
|
+
*/
|
|
107
|
+
grant?: GrantJudge
|
|
101
108
|
}
|
|
102
109
|
|
|
103
110
|
/**
|
|
@@ -218,6 +225,21 @@ export interface KernelOptions {
|
|
|
218
225
|
defaultEntityCacheTtlMs?: number
|
|
219
226
|
eventRouter?: RoutesConfig
|
|
220
227
|
sse?: SSEConfig
|
|
228
|
+
/**
|
|
229
|
+
* Where screens remember their route state by default (#2146): a slug the
|
|
230
|
+
* framework knows (`'url'`, `'local_storage'`, `'cookie'`, `'memory'`,
|
|
231
|
+
* `'none'`), or a persister instance.
|
|
232
|
+
*
|
|
233
|
+
* The app-wide floor. An entity overrides it for every screen showing that
|
|
234
|
+
* entity; a single list overrides it for itself. Unset means `'url'`, which
|
|
235
|
+
* is the only medium where what you are looking at is also what you can
|
|
236
|
+
* send someone.
|
|
237
|
+
*
|
|
238
|
+
* An unknown slug throws at list construction rather than falling back —
|
|
239
|
+
* a persistence choice that quietly does something else is the failure
|
|
240
|
+
* ADR 0011 forbids.
|
|
241
|
+
*/
|
|
242
|
+
routeState?: string | RouteStatePersister
|
|
221
243
|
debugBar?: DebugBarConfig
|
|
222
244
|
notifications?: NotificationsConfig
|
|
223
245
|
toast?: Record<string, unknown>
|
package/src/kernel/Kernel.vue.ts
CHANGED
|
@@ -323,6 +323,9 @@ export function applyVueMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
323
323
|
}
|
|
324
324
|
|
|
325
325
|
app.provide('qdadmHooks', this.hookRegistry)
|
|
326
|
+
// The app-wide route-state default (#2146). Provided even when unset, so
|
|
327
|
+
// a list can tell "nobody chose" from "somebody chose and it was null".
|
|
328
|
+
app.provide('qdadmRouteState', this.options.routeState ?? null)
|
|
326
329
|
app.provide('qdadmDeferred', this.deferred)
|
|
327
330
|
app.provide('qdadmLayoutComponents', this.layoutComponents)
|
|
328
331
|
// Surface PrimeVue presence so host-rendered <QdadmRoot /> can
|
package/src/kernel/SignalBus.ts
CHANGED
|
@@ -43,6 +43,12 @@ export const SIGNALS = {
|
|
|
43
43
|
AUTH_LOGOUT: 'auth:logout',
|
|
44
44
|
AUTH_EXPIRED: 'auth:expired', // Emitted on 401/403 API responses
|
|
45
45
|
|
|
46
|
+
// Security signals
|
|
47
|
+
// The application's permission answers changed — every screen re-evaluates
|
|
48
|
+
// (#2225). Emit it only when an answer actually differs: it remounts the app,
|
|
49
|
+
// which discards unsaved form state.
|
|
50
|
+
SECURITY_CHANGED: 'security:changed',
|
|
51
|
+
|
|
46
52
|
// API error signals
|
|
47
53
|
API_ERROR: 'api:error', // Emitted on any API error { status, message, url }
|
|
48
54
|
} as const
|
|
@@ -41,7 +41,7 @@ export interface AuthEvent {
|
|
|
41
41
|
* Auth entry types
|
|
42
42
|
*/
|
|
43
43
|
export interface AuthEntry extends CollectorEntry {
|
|
44
|
-
type: 'status' | 'user' | 'impersonated' | 'token' | 'user-permissions' | 'hierarchy' | 'role-permissions' | 'permissions' | 'adapter' | 'error'
|
|
44
|
+
type: 'status' | 'user' | 'impersonated' | 'token' | 'user-permissions' | 'hierarchy' | 'role-permissions' | 'permissions' | 'delegated-verdicts' | 'adapter' | 'error'
|
|
45
45
|
label?: string
|
|
46
46
|
message?: string
|
|
47
47
|
data?: unknown
|
|
@@ -372,6 +372,20 @@ export class AuthCollector extends Collector<AuthEntry> {
|
|
|
372
372
|
label: 'Permissions',
|
|
373
373
|
data: permissions
|
|
374
374
|
})
|
|
375
|
+
|
|
376
|
+
// With a delegated judge (#2225) the role matrix above is not the
|
|
377
|
+
// authority, and showing it alone would mislead. Show what the
|
|
378
|
+
// checker actually answers for every registered key — the judge's
|
|
379
|
+
// verdicts, with the matrix only where the judge abstains.
|
|
380
|
+
const judged = (this._securityChecker ?? authCtx?.security) as SecurityChecker | null | undefined
|
|
381
|
+
if (judged?.grant) {
|
|
382
|
+
entries.push({
|
|
383
|
+
timestamp: Date.now(),
|
|
384
|
+
type: 'delegated-verdicts',
|
|
385
|
+
label: 'Delegated Judgement (security.grant)',
|
|
386
|
+
data: Object.fromEntries(permissions.map((key: string) => [key, judged.isGranted(key)]))
|
|
387
|
+
})
|
|
388
|
+
}
|
|
375
389
|
}
|
|
376
390
|
} catch (e) {
|
|
377
391
|
console.warn('[AuthCollector] Error accessing permissionRegistry:', e)
|
package/src/plugin.ts
CHANGED
|
@@ -107,7 +107,7 @@ export interface QdadmPlugin {
|
|
|
107
107
|
export function createQdadm(options: QdadmOptions): QdadmPlugin {
|
|
108
108
|
return {
|
|
109
109
|
install(app: App) {
|
|
110
|
-
//
|
|
110
|
+
// The contracts this plugin cannot run without.
|
|
111
111
|
if (!options.entityFactory && !options.orchestrator && !options.managers) {
|
|
112
112
|
throw new Error('[qdadm] entityFactory, orchestrator, or managers required')
|
|
113
113
|
}
|
|
@@ -103,9 +103,20 @@ export function filterItems<T>(
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
/** Substring match on every string field (the adapters' full-text search). */
|
|
106
|
-
export function searchItems<T>(items: T[], search?:
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
export function searchItems<T>(items: T[], search?: unknown): T[] {
|
|
107
|
+
// A NUMBER is a search term too (#2147). This used to require a string and
|
|
108
|
+
// return the list untouched for anything else — so searching an order id
|
|
109
|
+
// that had been through a numeric round trip filtered nothing, silently,
|
|
110
|
+
// which is the failure ADR 0011 forbids. Objects and booleans are still
|
|
111
|
+
// ignored: there is no sensible text for them.
|
|
112
|
+
const term =
|
|
113
|
+
typeof search === 'string'
|
|
114
|
+
? search
|
|
115
|
+
: typeof search === 'number' || typeof search === 'bigint'
|
|
116
|
+
? String(search)
|
|
117
|
+
: ''
|
|
118
|
+
if (!term.trim()) return items
|
|
119
|
+
const query = term.toLowerCase().trim()
|
|
109
120
|
return items.filter((item) => {
|
|
110
121
|
for (const value of Object.values(item as Record<string, unknown>)) {
|
|
111
122
|
if (typeof value === 'string' && value.toLowerCase().includes(query)) {
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One state, several media (#2146).
|
|
3
|
+
*
|
|
4
|
+
* The seam's signature was chosen so composition would be expressible without
|
|
5
|
+
* changing it, and this is the case that needed it: qdadm's own default.
|
|
6
|
+
* Filters, search and the page belong in a link; rows-per-page does not — it
|
|
7
|
+
* is a comfort setting somebody chose once, and putting it in the URL would
|
|
8
|
+
* mean a link imposing the sender's row count on the reader.
|
|
9
|
+
*
|
|
10
|
+
* So the default routes by key: everything to the URL, `pageSize` to the
|
|
11
|
+
* cookie it has always lived in.
|
|
12
|
+
*
|
|
13
|
+
* ```js
|
|
14
|
+
* new CompositePersister({
|
|
15
|
+
* fallback: new UrlPersister({ router, route }),
|
|
16
|
+
* keys: { pageSize: { persister: new CookiePersister(), scope: 'app' } },
|
|
17
|
+
* })
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* A ROUTED KEY MAY PIN ITS OWN SCOPE. `pageSize` is remembered once for the
|
|
21
|
+
* whole app rather than per list — that is what the year-long
|
|
22
|
+
* `qdadm_pageSize` cookie already did, and expressing it here makes it a
|
|
23
|
+
* visible decision instead of an accident of where the code happened to live.
|
|
24
|
+
*/
|
|
25
|
+
import type { RouteStatePersister } from './RouteStatePersister'
|
|
26
|
+
|
|
27
|
+
export interface RoutedKey {
|
|
28
|
+
persister: RouteStatePersister
|
|
29
|
+
/**
|
|
30
|
+
* Remember this key under a fixed scope instead of the caller's.
|
|
31
|
+
*
|
|
32
|
+
* For state that is one setting across the whole app rather than one per
|
|
33
|
+
* screen. Leave it out and the key follows the scope like everything else.
|
|
34
|
+
*/
|
|
35
|
+
scope?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CompositePersisterOptions {
|
|
39
|
+
/** Where anything unrouted goes. */
|
|
40
|
+
fallback: RouteStatePersister
|
|
41
|
+
/** Keys that live somewhere else. */
|
|
42
|
+
keys?: Record<string, RoutedKey | RouteStatePersister>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class CompositePersister implements RouteStatePersister {
|
|
46
|
+
readonly name: string
|
|
47
|
+
|
|
48
|
+
private readonly _fallback: RouteStatePersister
|
|
49
|
+
private readonly _keys: Map<string, RoutedKey>
|
|
50
|
+
|
|
51
|
+
constructor(options: CompositePersisterOptions) {
|
|
52
|
+
this._fallback = options.fallback
|
|
53
|
+
this._keys = new Map(
|
|
54
|
+
Object.entries(options.keys ?? {}).map(([key, routed]) => [
|
|
55
|
+
key,
|
|
56
|
+
'persister' in routed ? routed : { persister: routed },
|
|
57
|
+
])
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
const media = new Set([
|
|
61
|
+
this._fallback.name,
|
|
62
|
+
...[...this._keys.values()].map((routed) => routed.persister.name),
|
|
63
|
+
])
|
|
64
|
+
this.name = `composite(${[...media].join('+')})`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The fallback's state, with each routed key overlaid from its own medium.
|
|
69
|
+
*
|
|
70
|
+
* Order matters: a routed key wins even if the fallback happens to hold one
|
|
71
|
+
* under the same name — a stale `?pageSize=50` left in somebody's URL must
|
|
72
|
+
* not beat the cookie that owns the setting.
|
|
73
|
+
*/
|
|
74
|
+
read(scope: string): Record<string, unknown> | null {
|
|
75
|
+
const base = this._fallback.read(scope)
|
|
76
|
+
const state: Record<string, unknown> = base ? { ...base } : {}
|
|
77
|
+
let found = base !== null
|
|
78
|
+
|
|
79
|
+
// Read each medium once, however many keys it owns.
|
|
80
|
+
for (const [medium, keys] of this._byMedium()) {
|
|
81
|
+
const stored = medium.persister.read(medium.scope ?? scope)
|
|
82
|
+
if (!stored) continue
|
|
83
|
+
for (const key of keys) {
|
|
84
|
+
if (stored[key] === undefined) continue
|
|
85
|
+
found = true
|
|
86
|
+
state[key] = stored[key]
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return found ? state : null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Split the state by owner and write each medium once.
|
|
95
|
+
*
|
|
96
|
+
* A routed key ABSENT from `state` is still passed on as absent, so the
|
|
97
|
+
* empty-means-removed rule reaches the medium that owns it — otherwise
|
|
98
|
+
* clearing a filter would leave it behind in whichever store it lived in.
|
|
99
|
+
*/
|
|
100
|
+
write(scope: string, state: Record<string, unknown>): void {
|
|
101
|
+
const forFallback: Record<string, unknown> = {}
|
|
102
|
+
for (const [key, value] of Object.entries(state)) {
|
|
103
|
+
if (this._keys.has(key)) continue
|
|
104
|
+
forFallback[key] = value
|
|
105
|
+
}
|
|
106
|
+
this._fallback.write(scope, forFallback)
|
|
107
|
+
|
|
108
|
+
for (const [medium, keys] of this._byMedium()) {
|
|
109
|
+
const slice: Record<string, unknown> = {}
|
|
110
|
+
for (const key of keys) slice[key] = state[key]
|
|
111
|
+
medium.persister.write(medium.scope ?? scope, slice)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Clear the fallback, and any routed key that follows the caller's scope.
|
|
117
|
+
*
|
|
118
|
+
* A key PINNED to its own scope is deliberately left alone: `pageSize` is
|
|
119
|
+
* an app-wide setting somebody chose, and "clear this list's filters" is
|
|
120
|
+
* not a request to reset it. Clearing it here would make one list's clear
|
|
121
|
+
* button change every other list's row count.
|
|
122
|
+
*/
|
|
123
|
+
clear(scope: string): void {
|
|
124
|
+
this._fallback.clear(scope)
|
|
125
|
+
for (const [medium, keys] of this._byMedium()) {
|
|
126
|
+
if (medium.scope !== undefined) continue
|
|
127
|
+
const slice: Record<string, unknown> = {}
|
|
128
|
+
for (const key of keys) slice[key] = null
|
|
129
|
+
medium.persister.write(scope, slice)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Group routed keys by the (persister, scope) pair that owns them. */
|
|
134
|
+
private _byMedium(): Map<RoutedKey, string[]> {
|
|
135
|
+
const grouped = new Map<string, { medium: RoutedKey; keys: string[] }>()
|
|
136
|
+
for (const [key, routed] of this._keys) {
|
|
137
|
+
const id = `${routed.persister.name} ${routed.scope ?? ''}`
|
|
138
|
+
const existing = grouped.get(id)
|
|
139
|
+
if (existing) existing.keys.push(key)
|
|
140
|
+
else grouped.set(id, { medium: routed, keys: [key] })
|
|
141
|
+
}
|
|
142
|
+
return new Map([...grouped.values()].map(({ medium, keys }) => [medium, keys]))
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route state in a cookie (#2146).
|
|
3
|
+
*
|
|
4
|
+
* Here because qdadm already does this — page size lives in a year-long
|
|
5
|
+
* `qdadm_pageSize` cookie — and a seam that could not express what the
|
|
6
|
+
* framework already does would be a seam with a hole in it.
|
|
7
|
+
*
|
|
8
|
+
* **Every cookie is sent on every request to your origin.** That is the whole
|
|
9
|
+
* difference from web storage, and the reason this persister is shaped
|
|
10
|
+
* differently: ONE cookie per scope holding a JSON blob, not one per key. A
|
|
11
|
+
* list with eight filters would otherwise put eight cookies on every image,
|
|
12
|
+
* script and API call the page makes.
|
|
13
|
+
*
|
|
14
|
+
* Which also means: keep it small, and do not reach for this when
|
|
15
|
+
* `local_storage` would do. The case for a cookie is state the SERVER needs
|
|
16
|
+
* to see — a rows-per-page the backend renders against, say. If the server
|
|
17
|
+
* never reads it, it is paying wire cost for nothing.
|
|
18
|
+
*/
|
|
19
|
+
import type { RouteStatePersister } from './RouteStatePersister'
|
|
20
|
+
import { underscoreScopeLocator, type RouteStateScopeLocator } from './keyLocator'
|
|
21
|
+
|
|
22
|
+
/** The document surface this needs, so a test needs no browser. */
|
|
23
|
+
export interface CookieJar {
|
|
24
|
+
cookie: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CookiePersisterOptions {
|
|
28
|
+
/** Defaults to `document`. */
|
|
29
|
+
jar?: CookieJar
|
|
30
|
+
/** How a scope becomes a cookie name. Defaults to `qdadm_offers`. */
|
|
31
|
+
scopeLocator?: RouteStateScopeLocator
|
|
32
|
+
/** Lifetime in days. Defaults to 365 — what `qdadm_pageSize` already used. */
|
|
33
|
+
maxAgeDays?: number
|
|
34
|
+
/** `SameSite` attribute. Defaults to `Lax`. */
|
|
35
|
+
sameSite?: 'Strict' | 'Lax' | 'None'
|
|
36
|
+
/** Path the cookie applies to. Defaults to `/`. */
|
|
37
|
+
path?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class CookiePersister implements RouteStatePersister {
|
|
41
|
+
readonly name = 'cookie'
|
|
42
|
+
|
|
43
|
+
private readonly _jar: CookieJar
|
|
44
|
+
private readonly _scopes: RouteStateScopeLocator
|
|
45
|
+
private readonly _maxAgeDays: number
|
|
46
|
+
private readonly _sameSite: string
|
|
47
|
+
private readonly _path: string
|
|
48
|
+
|
|
49
|
+
constructor(options: CookiePersisterOptions = {}) {
|
|
50
|
+
this._jar = options.jar ?? (document as unknown as CookieJar)
|
|
51
|
+
this._scopes = options.scopeLocator ?? underscoreScopeLocator
|
|
52
|
+
this._maxAgeDays = options.maxAgeDays ?? 365
|
|
53
|
+
this._sameSite = options.sameSite ?? 'Lax'
|
|
54
|
+
this._path = options.path ?? '/'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
read(scope: string): Record<string, unknown> | null {
|
|
58
|
+
const raw = this._readCookie(this._scopes.encode(scope))
|
|
59
|
+
if (raw === null) return null
|
|
60
|
+
try {
|
|
61
|
+
const parsed = JSON.parse(decodeURIComponent(raw))
|
|
62
|
+
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null
|
|
63
|
+
} catch {
|
|
64
|
+
// Somebody else's cookie under our name, or a truncated one. Ignoring it
|
|
65
|
+
// is right here: unlike a single key, a corrupt blob tells us nothing
|
|
66
|
+
// partial worth keeping.
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
write(scope: string, state: Record<string, unknown>): void {
|
|
72
|
+
// The blob is rewritten whole, so the empty-means-absent rule is applied
|
|
73
|
+
// by dropping keys rather than by removing entries.
|
|
74
|
+
const kept: Record<string, unknown> = {}
|
|
75
|
+
for (const [key, value] of Object.entries(state)) {
|
|
76
|
+
if (value === null || value === undefined || value === '') continue
|
|
77
|
+
kept[key] = value
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Nothing left to remember: take the cookie off the wire rather than
|
|
81
|
+
// shipping `{}` on every request for the next year.
|
|
82
|
+
if (!Object.keys(kept).length) {
|
|
83
|
+
this.clear(scope)
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
this._writeCookie(this._scopes.encode(scope), encodeURIComponent(JSON.stringify(kept)))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
clear(scope: string): void {
|
|
91
|
+
const name = this._scopes.encode(scope)
|
|
92
|
+
this._jar.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=${this._path}; SameSite=${this._sameSite}`
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private _readCookie(name: string): string | null {
|
|
96
|
+
for (const part of this._jar.cookie.split(';')) {
|
|
97
|
+
const separator = part.indexOf('=')
|
|
98
|
+
if (separator === -1) continue
|
|
99
|
+
if (part.slice(0, separator).trim() !== name) continue
|
|
100
|
+
return part.slice(separator + 1)
|
|
101
|
+
}
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private _writeCookie(name: string, value: string): void {
|
|
106
|
+
const expires = new Date(Date.now() + this._maxAgeDays * 864e5).toUTCString()
|
|
107
|
+
this._jar.cookie = `${name}=${value}; expires=${expires}; path=${this._path}; SameSite=${this._sameSite}`
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route state in memory (#2146).
|
|
3
|
+
*
|
|
4
|
+
* Survives navigating away and back within the running app; does not survive
|
|
5
|
+
* a reload, and is invisible to a link. That makes it the right answer for
|
|
6
|
+
* state that would be noise anywhere else — a wizard's step, a panel a user
|
|
7
|
+
* opened once — and the right answer in tests, where a shared `localStorage`
|
|
8
|
+
* leaks one case into the next.
|
|
9
|
+
*
|
|
10
|
+
* THE STORE OUTLIVES THE PERSISTER ON PURPOSE. A list builds its persister on
|
|
11
|
+
* mount; if the state lived on the instance it would die on unmount, which is
|
|
12
|
+
* exactly the navigation this is supposed to survive. So the default store is
|
|
13
|
+
* module-level and shared. Pass your own `store` to get an isolated one —
|
|
14
|
+
* which is what a test usually wants.
|
|
15
|
+
*/
|
|
16
|
+
import type { RouteStatePersister } from './RouteStatePersister'
|
|
17
|
+
|
|
18
|
+
export type RouteStateMemoryStore = Map<string, Record<string, unknown>>
|
|
19
|
+
|
|
20
|
+
/** The shared default. See the note above on why this is module-level. */
|
|
21
|
+
const defaultStore: RouteStateMemoryStore = new Map()
|
|
22
|
+
|
|
23
|
+
export interface MemoryPersisterOptions {
|
|
24
|
+
store?: RouteStateMemoryStore
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class MemoryPersister implements RouteStatePersister {
|
|
28
|
+
readonly name = 'memory'
|
|
29
|
+
|
|
30
|
+
private readonly _store: RouteStateMemoryStore
|
|
31
|
+
|
|
32
|
+
constructor(options: MemoryPersisterOptions = {}) {
|
|
33
|
+
this._store = options.store ?? defaultStore
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
read(scope: string): Record<string, unknown> | null {
|
|
37
|
+
const stored = this._store.get(scope)
|
|
38
|
+
// Copied out, so a caller mutating what it read cannot rewrite history.
|
|
39
|
+
return stored ? { ...stored } : null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
write(scope: string, state: Record<string, unknown>): void {
|
|
43
|
+
const kept: Record<string, unknown> = {}
|
|
44
|
+
for (const [key, value] of Object.entries(state)) {
|
|
45
|
+
if (value === null || value === undefined || value === '') continue
|
|
46
|
+
kept[key] = value
|
|
47
|
+
}
|
|
48
|
+
if (!Object.keys(kept).length) this._store.delete(scope)
|
|
49
|
+
else this._store.set(scope, kept)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
clear(scope: string): void {
|
|
53
|
+
this._store.delete(scope)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Empty the shared store — for tests that use the default. */
|
|
58
|
+
export function clearRouteStateMemory(): void {
|
|
59
|
+
defaultStore.clear()
|
|
60
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route state remembered nowhere (#2146).
|
|
3
|
+
*
|
|
4
|
+
* The way to say "this screen forgets" and have it read as a decision. An
|
|
5
|
+
* embedded list inside a detail view, a picker in a dialog, a preview — none
|
|
6
|
+
* of them should put anything in the address bar, and none of them should
|
|
7
|
+
* leave anything behind.
|
|
8
|
+
*
|
|
9
|
+
* A no-op object looks like the thing
|
|
10
|
+
* [ADR 0011](../../docs/adr/0011-no-silent-no-ops.md) forbids, and the
|
|
11
|
+
* difference is the whole point: this one is NAMED. Nothing falls back to it,
|
|
12
|
+
* nothing resolves to it by accident, and an unknown slug throws rather than
|
|
13
|
+
* landing here. It does nothing because somebody wrote `routeState: 'none'`,
|
|
14
|
+
* and that sentence is in the app's source where the next reader will find
|
|
15
|
+
* it.
|
|
16
|
+
*/
|
|
17
|
+
import type { RouteStatePersister } from './RouteStatePersister'
|
|
18
|
+
|
|
19
|
+
export class NullPersister implements RouteStatePersister {
|
|
20
|
+
readonly name = 'none'
|
|
21
|
+
|
|
22
|
+
read(): null {
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
write(): void {
|
|
27
|
+
/* deliberately nothing — see the note above */
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
clear(): void {
|
|
31
|
+
/* deliberately nothing */
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a route's state is remembered (#2146).
|
|
3
|
+
*
|
|
4
|
+
* Not "list state": the list is one CONSUMER of routing state, not the
|
|
5
|
+
* subject. A detail page's active tab, a dashboard's date range and a child
|
|
6
|
+
* page's own state are the same problem, and each would invent its own answer
|
|
7
|
+
* if this seam did not exist — which is exactly how qdadm ended up with five
|
|
8
|
+
* unrelated persistence sites, one of them a year-long cookie.
|
|
9
|
+
*
|
|
10
|
+
* The state is opaque on purpose. Every consumer has its own shape, and a
|
|
11
|
+
* fixed one here would only fit whichever consumer was written first.
|
|
12
|
+
*
|
|
13
|
+
* NOT for chrome preferences. A collapsed sidebar belongs to the person, not
|
|
14
|
+
* to the route: sending someone a link must not fold their menu. The line is
|
|
15
|
+
* *what I am looking at* versus *how I like my interface*.
|
|
16
|
+
*/
|
|
17
|
+
export interface RouteStatePersister {
|
|
18
|
+
/** Everything remembered under this scope, or null when nothing is. */
|
|
19
|
+
read(scope: string): Record<string, unknown> | null
|
|
20
|
+
/**
|
|
21
|
+
* Remember this state under this scope.
|
|
22
|
+
*
|
|
23
|
+
* A key whose value is `null`, `undefined` or `''` is REMOVED rather than
|
|
24
|
+
* stored empty — so a pristine screen leaves no trace, and a shared link
|
|
25
|
+
* carries only what was actually chosen.
|
|
26
|
+
*/
|
|
27
|
+
write(scope: string, state: Record<string, unknown>): void
|
|
28
|
+
clear(scope: string): void
|
|
29
|
+
/** For diagnostics: which persister answered. */
|
|
30
|
+
readonly name: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* How a consumer names itself.
|
|
35
|
+
*
|
|
36
|
+
* The scope is what makes two lists on one route stop fighting over `page` —
|
|
37
|
+
* the collision `page-compositions.md` currently tells people to avoid by
|
|
38
|
+
* switching persistence off entirely.
|
|
39
|
+
*/
|
|
40
|
+
export type RouteStateScope = string
|
|
41
|
+
|
|
42
|
+
/** A handler as the bootstrap declares it: a slug, or an instance. */
|
|
43
|
+
export type RouteStatePersisterSpec = string | RouteStatePersister
|
|
44
|
+
|
|
45
|
+
/** Anything that can build a persister from a slug. */
|
|
46
|
+
export type RouteStatePersisterFactory = (slug: string) => RouteStatePersister | null
|
|
47
|
+
|
|
48
|
+
/** An instance, duck-typed — the same test the storage factory applies. */
|
|
49
|
+
export function isRouteStatePersister(value: unknown): value is RouteStatePersister {
|
|
50
|
+
if (!value || typeof value !== 'object') return false
|
|
51
|
+
const candidate = value as RouteStatePersister
|
|
52
|
+
return (
|
|
53
|
+
typeof candidate.read === 'function' &&
|
|
54
|
+
typeof candidate.write === 'function' &&
|
|
55
|
+
typeof candidate.clear === 'function'
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Turn a spec into a persister.
|
|
61
|
+
*
|
|
62
|
+
* An unknown slug THROWS. It does not fall back to the URL: a persistence
|
|
63
|
+
* choice that silently does something else is the exact failure
|
|
64
|
+
* [ADR 0011](../../docs/adr/0011-no-silent-no-ops.md) exists to forbid, and
|
|
65
|
+
* the one this seam is cleaning up after.
|
|
66
|
+
*/
|
|
67
|
+
export function resolveRouteStatePersister(
|
|
68
|
+
spec: RouteStatePersisterSpec,
|
|
69
|
+
factory: RouteStatePersisterFactory
|
|
70
|
+
): RouteStatePersister {
|
|
71
|
+
if (isRouteStatePersister(spec)) return spec
|
|
72
|
+
|
|
73
|
+
if (typeof spec === 'string') {
|
|
74
|
+
const built = factory(spec)
|
|
75
|
+
if (built) return built
|
|
76
|
+
throw new Error(
|
|
77
|
+
`[qdadm] Unknown route-state persister "${spec}". ` +
|
|
78
|
+
`Pass a known slug, or an object with read/write/clear.`
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
throw new Error(
|
|
83
|
+
`[qdadm] Invalid route-state persister: ${typeof spec}. ` +
|
|
84
|
+
`Expected a slug or an object with read/write/clear.`
|
|
85
|
+
)
|
|
86
|
+
}
|