@svadmin/core 0.49.1 → 0.52.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 +32 -0
- package/package.json +21 -4
- package/src/access-control-contract.ts +250 -0
- package/src/adapters/casbin.ts +20 -14
- package/src/adapters/casl.ts +22 -19
- package/src/admin-tool.ts +7 -7
- package/src/audit-contract.ts +105 -0
- package/src/audit.ts +88 -128
- package/src/auth-hooks.svelte.ts +593 -261
- package/src/auth-mutation-contract.ts +143 -0
- package/src/auth-query-contract.ts +97 -0
- package/src/captured-mutation.svelte.ts +45 -0
- package/src/chatProvider.svelte.ts +1 -1
- package/src/command-contract.ts +195 -0
- package/src/context.svelte.ts +217 -205
- package/src/contract-context.svelte.ts +37 -0
- package/src/contract-form.svelte.ts +566 -0
- package/src/create-contract.ts +56 -0
- package/src/create-hooks.svelte.ts +347 -0
- package/src/create-many-contract.ts +90 -0
- package/src/create-many-hooks.svelte.ts +396 -0
- package/src/data-transfer.svelte.ts +1 -215
- package/src/defined-options.ts +50 -0
- package/src/delete-contract.ts +50 -0
- package/src/delete-hooks.svelte.ts +403 -0
- package/src/delete-many-contract.ts +60 -0
- package/src/delete-many-hooks.svelte.ts +402 -0
- package/src/enterprise.ts +4 -4
- package/src/export-format.ts +16 -14
- package/src/export-hooks.svelte.ts +191 -0
- package/src/feature-gate-contract.ts +53 -0
- package/src/hook-utils.svelte.ts +53 -105
- package/src/hooks.svelte.ts +19 -774
- package/src/http.ts +88 -87
- package/src/i18n.svelte.ts +56 -2
- package/src/import-contract.ts +106 -0
- package/src/import-hooks.svelte.ts +310 -0
- package/src/index.ts +77 -28
- package/src/inferencer-graphql.ts +7 -6
- package/src/infinite-query.svelte.ts +228 -0
- package/src/invalidation-contract.ts +26 -0
- package/src/live-sse.ts +91 -127
- package/src/live-subscription.svelte.ts +130 -0
- package/src/live-transport.ts +127 -0
- package/src/live-websocket.ts +85 -118
- package/src/live.svelte.ts +66 -68
- package/src/notification.svelte.ts +17 -15
- package/src/permission-catalog.ts +121 -0
- package/src/permission-hints-contract.ts +48 -0
- package/src/permissions.svelte.ts +112 -59
- package/src/plain-data.ts +61 -0
- package/src/provider-bundle.ts +85 -79
- package/src/provider-response.ts +35 -0
- package/src/query-hooks.svelte.ts +241 -286
- package/src/query-invalidation.ts +172 -0
- package/src/query-keys.ts +221 -110
- package/src/query-session.svelte.ts +216 -0
- package/src/query-snapshot.ts +144 -0
- package/src/reactive-projection.ts +53 -0
- package/src/record-decoder.ts +63 -0
- package/src/resource-binding.svelte.ts +19 -0
- package/src/resource-contract.ts +595 -0
- package/src/resource-mutation-types.ts +30 -0
- package/src/resource-schemas.ts +371 -0
- package/src/resource-transport-config.ts +156 -0
- package/src/route-parsing.ts +11 -11
- package/src/router-provider.ts +64 -59
- package/src/router.ts +8 -3
- package/src/routing-hooks.svelte.ts +90 -62
- package/src/schema-validation.ts +92 -0
- package/src/schema.ts +13 -0
- package/src/select-options.ts +95 -0
- package/src/select-query.svelte.ts +191 -0
- package/src/session-query.svelte.ts +88 -0
- package/src/strict-command-hooks.svelte.ts +333 -0
- package/src/strict-hooks.svelte.ts +198 -0
- package/src/table-hooks.svelte.ts +180 -174
- package/src/table-state.svelte.ts +117 -117
- package/src/task-contract.ts +87 -0
- package/src/task-hooks.svelte.ts +193 -149
- package/src/task-provider.ts +115 -0
- package/src/task-subscription.ts +132 -0
- package/src/theme.svelte.ts +155 -154
- package/src/toast.svelte.ts +38 -37
- package/src/types.ts +55 -103
- package/src/unsafe.ts +7 -0
- package/src/update-contract.ts +65 -0
- package/src/update-hooks.svelte.ts +347 -0
- package/src/update-many-contract.ts +79 -0
- package/src/update-many-hooks.svelte.ts +373 -0
- package/src/url-sync.ts +11 -10
- package/src/useCan.ts +96 -31
- package/src/useParsed.svelte.ts +18 -14
- package/src/useStepsForm.svelte.ts +123 -63
- package/src/utility-hooks.svelte.ts +101 -158
- package/src/form-hooks.svelte.ts +0 -889
- package/src/mutation-hooks.svelte.ts +0 -561
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { Type, type Static } from '@sinclair/typebox';
|
|
2
|
+
import { checkExact } from './schema-validation';
|
|
3
|
+
import { snapshotPlainData } from './plain-data';
|
|
4
|
+
import type { AccessControlOptions, AccessControlProvider, CanParams } from './permissions.svelte';
|
|
5
|
+
|
|
6
|
+
const applicationId = Type.String({ minLength: 1, maxLength: 512, pattern: '^\\S+$' });
|
|
7
|
+
const version = Type.String({ minLength: 1, maxLength: 64, pattern: '^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$' });
|
|
8
|
+
const digest = Type.String({ minLength: 64, maxLength: 64, pattern: '^[a-fA-F0-9]{64}$' });
|
|
9
|
+
const permission = Type.String({ minLength: 3, maxLength: 512, pattern: '^[a-z][a-z0-9._-]*:[a-z][a-z0-9._-]*$' });
|
|
10
|
+
|
|
11
|
+
// 内部契约复用同一 schema,避免注册选项、权限提示与适配器校验漂移。
|
|
12
|
+
export const permissionCatalogBindingSchema = Type.Object({
|
|
13
|
+
applicationId,
|
|
14
|
+
version,
|
|
15
|
+
digest: Type.Optional(digest),
|
|
16
|
+
}, { additionalProperties: false });
|
|
17
|
+
const catalog = Type.Object({
|
|
18
|
+
...permissionCatalogBindingSchema.properties,
|
|
19
|
+
permissions: Type.Array(permission, { minItems: 1 }),
|
|
20
|
+
}, { additionalProperties: false });
|
|
21
|
+
export const permissionSnapshotSchema = Type.Object({
|
|
22
|
+
applicationId,
|
|
23
|
+
catalogVersion: version,
|
|
24
|
+
catalogDigest: Type.Optional(digest),
|
|
25
|
+
permissions: Type.Array(permission),
|
|
26
|
+
}, { additionalProperties: false });
|
|
27
|
+
|
|
28
|
+
export type PermissionCatalogBinding = Readonly<Static<typeof permissionCatalogBindingSchema>>;
|
|
29
|
+
export type PermissionCatalog = Readonly<{
|
|
30
|
+
applicationId: string;
|
|
31
|
+
version: string;
|
|
32
|
+
digest?: string;
|
|
33
|
+
permissions: readonly string[];
|
|
34
|
+
}>;
|
|
35
|
+
export type PermissionSnapshot = Readonly<{
|
|
36
|
+
applicationId: string;
|
|
37
|
+
catalogVersion: string;
|
|
38
|
+
catalogDigest?: string;
|
|
39
|
+
permissions: readonly string[];
|
|
40
|
+
}>;
|
|
41
|
+
|
|
42
|
+
function freezeUnique(values: readonly string[], label: string): readonly string[] {
|
|
43
|
+
if (new Set(values).size !== values.length) throw new TypeError(`${label} contains duplicate permissions`);
|
|
44
|
+
return Object.freeze([...values]);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function canonicalPermission(resource: string, action: string): string {
|
|
48
|
+
if (typeof resource !== 'string' || typeof action !== 'string') throw new TypeError('Permission resource and action must be strings');
|
|
49
|
+
const value = `${resource}:${action}`;
|
|
50
|
+
if (value.length > 512 || !/^[a-z][a-z0-9._-]*:[a-z][a-z0-9._-]*$/.test(value)) {
|
|
51
|
+
throw new TypeError('Permission must use canonical resource:action syntax');
|
|
52
|
+
}
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function decodePermissionCatalog(value: unknown): PermissionCatalog {
|
|
57
|
+
const candidate = snapshotPlainData(value);
|
|
58
|
+
if (!checkExact(catalog, candidate)) throw new TypeError('Invalid permission catalog');
|
|
59
|
+
return Object.freeze({
|
|
60
|
+
...candidate,
|
|
61
|
+
...(candidate.digest === undefined ? {} : { digest: candidate.digest.toLowerCase() }),
|
|
62
|
+
permissions: freezeUnique(candidate.permissions, 'Permission catalog'),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function decodePermissionSnapshot(value: unknown): PermissionSnapshot {
|
|
67
|
+
const candidate = snapshotPlainData(value);
|
|
68
|
+
if (!checkExact(permissionSnapshotSchema, candidate)) throw new TypeError('Invalid permission snapshot');
|
|
69
|
+
return Object.freeze({
|
|
70
|
+
...candidate,
|
|
71
|
+
...(candidate.catalogDigest === undefined ? {} : { catalogDigest: candidate.catalogDigest.toLowerCase() }),
|
|
72
|
+
permissions: freezeUnique(candidate.permissions, 'Permission snapshot'),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface PermissionProjectionOptions {
|
|
77
|
+
readonly buttons?: AccessControlOptions['buttons'];
|
|
78
|
+
/** Application-owned expected catalog; mismatches and unknown grants fail closed. */
|
|
79
|
+
readonly catalog?: PermissionCatalog;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function assertCatalogBinding(snapshot: PermissionSnapshot, expected: PermissionCatalog): void {
|
|
83
|
+
if (snapshot.applicationId !== expected.applicationId
|
|
84
|
+
|| snapshot.catalogVersion !== expected.version
|
|
85
|
+
|| (expected.digest !== undefined && snapshot.catalogDigest !== expected.digest)) {
|
|
86
|
+
throw new TypeError('Permission snapshot does not match the expected application catalog');
|
|
87
|
+
}
|
|
88
|
+
const known = new Set(expected.permissions);
|
|
89
|
+
if (snapshot.permissions.some(grant => !known.has(grant))) {
|
|
90
|
+
throw new TypeError('Permission snapshot contains grants outside the expected catalog');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Projects a backend-verified snapshot into a UI-only provider.
|
|
96
|
+
* Supply catalog to check the application's expected binding. This is not
|
|
97
|
+
* signature verification and never replaces server-side authorization.
|
|
98
|
+
*/
|
|
99
|
+
export function createPermissionAccessControlProvider(
|
|
100
|
+
value: unknown,
|
|
101
|
+
options: PermissionProjectionOptions = {},
|
|
102
|
+
): AccessControlProvider {
|
|
103
|
+
const permissions = decodePermissionSnapshot(value);
|
|
104
|
+
if (options.catalog !== undefined) assertCatalogBinding(permissions, decodePermissionCatalog(options.catalog));
|
|
105
|
+
const granted = new Set(permissions.permissions);
|
|
106
|
+
const permissionCatalog = Object.freeze({
|
|
107
|
+
applicationId: permissions.applicationId,
|
|
108
|
+
version: permissions.catalogVersion,
|
|
109
|
+
...(permissions.catalogDigest === undefined ? {} : { digest: permissions.catalogDigest }),
|
|
110
|
+
});
|
|
111
|
+
return {
|
|
112
|
+
async can(params: CanParams) {
|
|
113
|
+
const required = canonicalPermission(params.resource, params.action);
|
|
114
|
+
return granted.has(required) ? { can: true } : { can: false, reason: `Missing permission: ${required}` };
|
|
115
|
+
},
|
|
116
|
+
options: Object.freeze({
|
|
117
|
+
...(options.buttons === undefined ? {} : { buttons: Object.freeze({ ...options.buttons }) }),
|
|
118
|
+
permissionCatalog,
|
|
119
|
+
}),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Type } from '@sinclair/typebox';
|
|
2
|
+
import { checkExact } from './schema-validation';
|
|
3
|
+
import { snapshotPlainData } from './plain-data';
|
|
4
|
+
import { decodePermissionSnapshot, permissionSnapshotSchema, type PermissionSnapshot } from './permission-catalog';
|
|
5
|
+
|
|
6
|
+
const permissionName = Type.String({ minLength: 1, pattern: '\\S' });
|
|
7
|
+
const permissionHintsSchema = Type.Union([
|
|
8
|
+
Type.Null(),
|
|
9
|
+
Type.Array(permissionName),
|
|
10
|
+
Type.Record(Type.String({ pattern: '\\S' }), Type.Boolean(), { additionalProperties: false }),
|
|
11
|
+
permissionSnapshotSchema,
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
export type PermissionHints = null | readonly string[] | Readonly<Record<string, boolean>> | PermissionSnapshot;
|
|
15
|
+
|
|
16
|
+
export class PermissionHintsError extends Error {
|
|
17
|
+
constructor(readonly code: 'INVALID_PERMISSION_HINTS' | 'PERMISSION_HINTS_FAILED') {
|
|
18
|
+
super(code === 'INVALID_PERMISSION_HINTS' ? 'Invalid permission hints.' : 'Permission hints request failed.');
|
|
19
|
+
this.name = 'PermissionHintsError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function decodePermissionHints(value: unknown): PermissionHints {
|
|
24
|
+
try {
|
|
25
|
+
const candidate = snapshotPlainData(value);
|
|
26
|
+
if (checkExact(permissionHintsSchema, candidate)) {
|
|
27
|
+
if (candidate === null) return null;
|
|
28
|
+
// 旧式布尔映射允许 applicationId 作为权限名称,不能仅凭键名识别快照。
|
|
29
|
+
if (!Array.isArray(candidate) && typeof candidate === 'object'
|
|
30
|
+
&& 'applicationId' in candidate && typeof candidate.applicationId === 'string') {
|
|
31
|
+
return decodePermissionSnapshot(candidate);
|
|
32
|
+
}
|
|
33
|
+
return Object.freeze(candidate);
|
|
34
|
+
}
|
|
35
|
+
} catch {
|
|
36
|
+
// Do not expose provider data or reflection errors.
|
|
37
|
+
}
|
|
38
|
+
throw new PermissionHintsError('INVALID_PERMISSION_HINTS');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function hasPermissionHint(hints: PermissionHints, permission: string): boolean {
|
|
42
|
+
if (hints === null) return false;
|
|
43
|
+
if (Array.isArray(hints)) return hints.includes(permission);
|
|
44
|
+
if ('permissions' in hints && Array.isArray(hints.permissions)) return hints.permissions.includes(permission);
|
|
45
|
+
const descriptor = Object.getOwnPropertyDescriptor(hints, permission);
|
|
46
|
+
const value: unknown = descriptor && 'value' in descriptor ? descriptor.value : undefined;
|
|
47
|
+
return value === true;
|
|
48
|
+
}
|
|
@@ -1,15 +1,23 @@
|
|
|
1
|
+
import { definedOptions } from './defined-options';
|
|
2
|
+
import {
|
|
3
|
+
accessControlFailure, prepareCanCheck, prepareCanBatchCheck, snapshotCanParams,
|
|
4
|
+
supersededAccessControl, captureAccessControlProvider,
|
|
5
|
+
} from './access-control-contract';
|
|
6
|
+
import { HttpError } from './types';
|
|
7
|
+
import { parseFeatureGateUser, snapshotFeatureGateConfig } from './feature-gate-contract';
|
|
8
|
+
import type { PermissionCatalogBinding } from './permission-catalog';
|
|
1
9
|
// Permission / Access Control
|
|
2
10
|
|
|
3
11
|
// ─── Types ────────────────────────────────────────────────────
|
|
4
12
|
|
|
5
13
|
/** Actions that can be checked for access control. Extensible via string literal union. */
|
|
6
|
-
export type Action
|
|
14
|
+
export type Action='list'|'show'|'create'|'edit'|'delete'|'export'|'field'|(string&{});
|
|
7
15
|
|
|
8
16
|
export interface CanParams {
|
|
9
17
|
resource: string;
|
|
10
18
|
action: Action;
|
|
11
|
-
params?: { id?: string
|
|
12
|
-
meta?: Record<string,
|
|
19
|
+
params?: { id?: string|number;[key: string]: unknown };
|
|
20
|
+
meta?: Record<string,unknown>;
|
|
13
21
|
}
|
|
14
22
|
|
|
15
23
|
export interface CanResult {
|
|
@@ -40,7 +48,9 @@ export interface CanResult {
|
|
|
40
48
|
* ```
|
|
41
49
|
*/
|
|
42
50
|
export interface AccessControlProvider {
|
|
43
|
-
can: (params: CanParams
|
|
51
|
+
can: (params: CanParams) => Promise<CanResult>;
|
|
52
|
+
/** Optional native batch capability; otherwise the framework checks each item through can. */
|
|
53
|
+
canMany?: (params: readonly CanParams[]) => Promise<CanResult[]>;
|
|
44
54
|
options?: {
|
|
45
55
|
buttons?: {
|
|
46
56
|
/** Enable access control checks on CRUD buttons globally. Default: false */
|
|
@@ -48,85 +58,132 @@ export interface AccessControlProvider {
|
|
|
48
58
|
/** Hide buttons when unauthorized instead of disabling. Default: false */
|
|
49
59
|
hideIfUnauthorized?: boolean;
|
|
50
60
|
};
|
|
61
|
+
permissionCatalog?: PermissionCatalogBinding;
|
|
51
62
|
};
|
|
52
63
|
}
|
|
53
64
|
|
|
65
|
+
export interface AccessControlOptions {
|
|
66
|
+
readonly buttons?: {
|
|
67
|
+
readonly enableAccessControl?: boolean;
|
|
68
|
+
readonly hideIfUnauthorized?: boolean;
|
|
69
|
+
};
|
|
70
|
+
readonly permissionCatalog?: PermissionCatalogBinding;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Checked, immutable projection. Use a new input object when changing provider configuration. */
|
|
74
|
+
export interface RegisteredAccessControlProvider {
|
|
75
|
+
readonly can: AccessControlProvider['can'];
|
|
76
|
+
readonly canMany?: NonNullable<AccessControlProvider['canMany']>;
|
|
77
|
+
readonly options: AccessControlOptions;
|
|
78
|
+
}
|
|
79
|
+
|
|
54
80
|
// ─── State ────────────────────────────────────────────────────
|
|
55
81
|
|
|
56
|
-
let provider:
|
|
82
|
+
let provider: RegisteredAccessControlProvider|null=$state.raw(null);
|
|
83
|
+
let providerRevision = 0;
|
|
84
|
+
let registrationIntent = 0;
|
|
85
|
+
const emptyOptions: AccessControlOptions = Object.freeze({});
|
|
57
86
|
|
|
58
87
|
// ─── API ──────────────────────────────────────────────────────
|
|
59
88
|
|
|
60
89
|
/**
|
|
61
|
-
* Register
|
|
90
|
+
* Register a checked snapshot. Input object identity denotes immutable configuration.
|
|
62
91
|
*/
|
|
63
92
|
export function setAccessControlProvider(p: AccessControlProvider): void {
|
|
64
|
-
|
|
93
|
+
const intent = ++registrationIntent;
|
|
94
|
+
let candidate: RegisteredAccessControlProvider;
|
|
95
|
+
try {
|
|
96
|
+
candidate = captureAccessControlProvider(p);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (intent !== registrationIntent) throw supersededAccessControl();
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
if (intent !== registrationIntent) throw supersededAccessControl();
|
|
102
|
+
providerRevision++;
|
|
103
|
+
provider=candidate;
|
|
65
104
|
}
|
|
66
105
|
|
|
67
106
|
/** Get the current AccessControlProvider (or null) */
|
|
68
|
-
export function getAccessControlProvider():
|
|
107
|
+
export function getAccessControlProvider(): RegisteredAccessControlProvider|null {
|
|
69
108
|
return provider;
|
|
70
109
|
}
|
|
71
110
|
|
|
72
111
|
export function resetAccessControlProvider(): void {
|
|
73
|
-
|
|
112
|
+
registrationIntent++;
|
|
113
|
+
providerRevision++;
|
|
114
|
+
provider=null;
|
|
74
115
|
}
|
|
75
116
|
|
|
76
117
|
/** Get global button options from the AccessControlProvider */
|
|
77
|
-
export function getAccessControlOptions() {
|
|
78
|
-
return provider?.options
|
|
118
|
+
export function getAccessControlOptions(): AccessControlOptions {
|
|
119
|
+
return provider?.options??emptyOptions;
|
|
79
120
|
}
|
|
80
121
|
|
|
81
122
|
/**
|
|
82
123
|
* UI access check supporting single capabilities or a batch.
|
|
83
124
|
* Its result can change browser presentation but never authorizes the backend action.
|
|
84
125
|
*/
|
|
85
|
-
export async function canAccessAsync(params: CanParams[]): Promise<CanResult[]>;
|
|
86
|
-
export async function canAccessAsync(resource: string,
|
|
87
|
-
export async function canAccessAsync(resourceOrBatch: string
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
126
|
+
export async function canAccessAsync(params: readonly CanParams[]): Promise<CanResult[]>;
|
|
127
|
+
export async function canAccessAsync(resource: string,action: Action,params?: CanParams['params'],meta?: CanParams['meta']): Promise<CanResult>;
|
|
128
|
+
export async function canAccessAsync(resourceOrBatch: string|readonly CanParams[],action?: Action,params?: CanParams['params'],meta?: CanParams['meta']): Promise<CanResult|CanResult[]> {
|
|
129
|
+
const captured = provider;
|
|
130
|
+
const revision = providerRevision;
|
|
131
|
+
const current = () => provider === captured && providerRevision === revision;
|
|
132
|
+
const checkCurrent = () => { if (!current()) throw supersededAccessControl(); };
|
|
133
|
+
try {
|
|
134
|
+
let execute: () => Promise<CanResult | CanResult[]>;
|
|
135
|
+
if (typeof resourceOrBatch === 'string') {
|
|
136
|
+
const request = snapshotCanParams({
|
|
137
|
+
resource: resourceOrBatch, ...definedOptions({ action, params, meta }),
|
|
138
|
+
});
|
|
139
|
+
execute = prepareCanCheck(captured, request);
|
|
140
|
+
} else {
|
|
141
|
+
if (action !== undefined || params !== undefined || meta !== undefined) {
|
|
142
|
+
throw new HttpError('Invalid access control request', 422, undefined, { code: 'INVALID_ACCESS_CONTROL_INPUT' });
|
|
143
|
+
}
|
|
144
|
+
execute = prepareCanBatchCheck(captured, resourceOrBatch, current);
|
|
145
|
+
}
|
|
146
|
+
checkCurrent();
|
|
147
|
+
const result = await execute();
|
|
148
|
+
checkCurrent();
|
|
149
|
+
return result;
|
|
150
|
+
} catch (error) {
|
|
151
|
+
const failure = accessControlFailure(error);
|
|
152
|
+
checkCurrent();
|
|
153
|
+
throw failure;
|
|
96
154
|
}
|
|
97
|
-
|
|
98
|
-
return provider.can({ resource: resourceOrBatch, action: action as string, params, meta }) as Promise<CanResult>;
|
|
99
155
|
}
|
|
100
156
|
|
|
101
157
|
// ─── Feature Gate ─────────────────────────────────────────────
|
|
102
158
|
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
roles?: string[];
|
|
106
|
-
/**
|
|
107
|
-
|
|
108
|
-
* 使用 minRole 时必须同时提供 roleHierarchy。
|
|
109
|
-
*/
|
|
110
|
-
minRole?: string;
|
|
111
|
-
/**
|
|
112
|
-
* 角色层级定义 (高权 → 低权),用于 minRole 比较。
|
|
113
|
-
* 由调用方按自身业务定义,框架不预设任何角色。
|
|
114
|
-
*/
|
|
115
|
-
roleHierarchy?: string[];
|
|
116
|
-
/** 仅用于前端展示的权限列表 (全部匹配)。 */
|
|
117
|
-
permissions?: string[];
|
|
159
|
+
interface FeatureGateConstraints {
|
|
160
|
+
/** Role list used only for frontend presentation. */
|
|
161
|
+
readonly roles?: readonly string[];
|
|
162
|
+
/** Permission list used only for frontend presentation; all entries must match. */
|
|
163
|
+
readonly permissions?: readonly string[];
|
|
118
164
|
}
|
|
119
165
|
|
|
120
|
-
|
|
166
|
+
export type FeatureGateConfig = FeatureGateConstraints & (
|
|
167
|
+
| {
|
|
168
|
+
/** Minimum required role, including all higher roles. Must occur in roleHierarchy. */
|
|
169
|
+
readonly minRole: string;
|
|
170
|
+
/** Nonempty unique roles, ordered from highest to lowest privilege. */
|
|
171
|
+
readonly roleHierarchy: readonly string[];
|
|
172
|
+
}
|
|
173
|
+
| { readonly minRole?: never; readonly roleHierarchy?: never }
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
/** Browser-side role and permission hints, not authorization credentials. */
|
|
121
177
|
export interface FeatureGateUser {
|
|
122
|
-
role: string;
|
|
123
|
-
permissions: string[];
|
|
178
|
+
readonly role: string;
|
|
179
|
+
readonly permissions: readonly string[];
|
|
124
180
|
}
|
|
125
181
|
|
|
126
182
|
/**
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
183
|
+
* Creates a feature gate used only for frontend presentation.
|
|
184
|
+
* Configuration is captured as a checked snapshot. User hints remain client-side
|
|
185
|
+
* presentation data; the backend must independently authorize operations.
|
|
186
|
+
* It assumes no role hierarchy and does not interpret wildcard permissions.
|
|
130
187
|
*
|
|
131
188
|
* @example
|
|
132
189
|
* ```ts
|
|
@@ -141,26 +198,22 @@ export interface FeatureGateUser {
|
|
|
141
198
|
* ```
|
|
142
199
|
*/
|
|
143
200
|
export function createFeatureGate(config: FeatureGateConfig): (user: FeatureGateUser) => boolean {
|
|
201
|
+
const rule = snapshotFeatureGateConfig(config);
|
|
144
202
|
return (user: FeatureGateUser): boolean => {
|
|
145
|
-
|
|
203
|
+
const subject = parseFeatureGateUser(user);
|
|
204
|
+
if (!subject) return false;
|
|
205
|
+
if(rule.roles&&!rule.roles.includes(subject.role)) {
|
|
146
206
|
return false;
|
|
147
207
|
}
|
|
148
208
|
|
|
149
|
-
if
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
} else {
|
|
154
|
-
const userIndex = hierarchy.indexOf(user.role);
|
|
155
|
-
const minIndex = hierarchy.indexOf(config.minRole);
|
|
156
|
-
if (userIndex === -1 || minIndex === -1 || userIndex > minIndex) {
|
|
157
|
-
return false;
|
|
158
|
-
}
|
|
159
|
-
}
|
|
209
|
+
if(rule.minRole !== undefined) {
|
|
210
|
+
const userIndex=rule.roleHierarchy.indexOf(subject.role);
|
|
211
|
+
const minIndex=rule.roleHierarchy.indexOf(rule.minRole);
|
|
212
|
+
if(userIndex===-1||userIndex>minIndex) return false;
|
|
160
213
|
}
|
|
161
214
|
|
|
162
|
-
if
|
|
163
|
-
if
|
|
215
|
+
if(rule.permissions&&rule.permissions.length>0) {
|
|
216
|
+
if(!rule.permissions.every((permission) => subject.permissions.includes(permission))) return false;
|
|
164
217
|
}
|
|
165
218
|
|
|
166
219
|
return true;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export type JsonValue = null | string | number | boolean | JsonValue[] | { [key: string]: JsonValue };
|
|
2
|
+
|
|
3
|
+
/** Copy validated JSON data without invoking getters or serialization hooks. */
|
|
4
|
+
export function snapshotPlainData(value: unknown): JsonValue {
|
|
5
|
+
function snapshot(value: unknown, ancestors: Set<object>): JsonValue {
|
|
6
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
|
|
7
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
8
|
+
if (typeof value !== 'object' || value === null || ancestors.has(value)) {
|
|
9
|
+
throw new TypeError('Invalid plain data');
|
|
10
|
+
}
|
|
11
|
+
const array = Array.isArray(value);
|
|
12
|
+
const prototype: unknown = Object.getPrototypeOf(value);
|
|
13
|
+
if ((!array && prototype !== Object.prototype && prototype !== null) ||
|
|
14
|
+
Object.getOwnPropertySymbols(value).length > 0) throw new TypeError('Invalid plain data');
|
|
15
|
+
ancestors.add(value);
|
|
16
|
+
try {
|
|
17
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
18
|
+
const read = (key: string): JsonValue => {
|
|
19
|
+
const descriptor = descriptors[key];
|
|
20
|
+
if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) throw new TypeError('Invalid plain data');
|
|
21
|
+
const field: unknown = descriptor.value;
|
|
22
|
+
return snapshot(field, ancestors);
|
|
23
|
+
};
|
|
24
|
+
if (array) {
|
|
25
|
+
const length: unknown = descriptors['length']?.value;
|
|
26
|
+
if (typeof length !== 'number' || !Number.isInteger(length) || length < 0 ||
|
|
27
|
+
Object.keys(descriptors).length !== length + 1) throw new TypeError('Invalid plain data');
|
|
28
|
+
return Array.from({ length }, (_, index) => read(String(index)));
|
|
29
|
+
}
|
|
30
|
+
const entries: [string, JsonValue][] = Object.keys(descriptors).map(key => [key, read(key)]);
|
|
31
|
+
return Object.fromEntries<JsonValue>(entries);
|
|
32
|
+
} finally {
|
|
33
|
+
ancestors.delete(value);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
return snapshot(value, new Set());
|
|
38
|
+
} catch {
|
|
39
|
+
// Reflection can throw for proxies; never retain the submitted value or error.
|
|
40
|
+
throw new TypeError('Invalid plain data');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Query cancellation is not JSON. Peel it before snapshots and reattach after. */
|
|
45
|
+
export function detachAbortSignal(value: unknown): { signal?: AbortSignal; rest: unknown } {
|
|
46
|
+
if (typeof value !== 'object' || value === null) return { rest: value };
|
|
47
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, 'signal');
|
|
48
|
+
const signal: unknown = descriptor && 'value' in descriptor ? descriptor.value : undefined;
|
|
49
|
+
if (!(signal instanceof AbortSignal)) return { rest: value };
|
|
50
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
51
|
+
delete descriptors['signal'];
|
|
52
|
+
const prototype: unknown = Object.getPrototypeOf(value);
|
|
53
|
+
if (Array.isArray(value) || (prototype !== Object.prototype && prototype !== null)) return { rest: value };
|
|
54
|
+
return { signal, rest: Object.defineProperties({}, descriptors) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Reattach a query-owned signal after JSON snapshots. */
|
|
58
|
+
export function attachAbortSignal<T extends object>(value: T, signal?: AbortSignal): T {
|
|
59
|
+
if (signal === undefined) return value;
|
|
60
|
+
return Object.assign(Object.create(Object.getPrototypeOf(value)), value, { signal });
|
|
61
|
+
}
|