@svadmin/core 0.50.0 → 0.53.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 +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 -776
- package/src/http.ts +88 -87
- package/src/i18n.svelte.ts +64 -2
- package/src/import-contract.ts +106 -0
- package/src/import-hooks.svelte.ts +310 -0
- package/src/index.ts +77 -30
- package/src/inferencer-graphql.ts +7 -6
- package/src/inferencer.ts +9 -0
- 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 -288
- package/src/query-invalidation.ts +172 -0
- package/src/query-keys.ts +221 -110
- package/src/query-session.svelte.ts +220 -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 +103 -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 +190 -180
- 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 +68 -110
- 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
- package/src/schema-form.ts +0 -31
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@svadmin/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.53.0",
|
|
4
4
|
"author": "zuohuadong",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
|
-
"url": "https://github.com/vibeunion/svadmin.git",
|
|
7
|
+
"url": "git+https://github.com/vibeunion/svadmin.git",
|
|
8
8
|
"directory": "packages/core"
|
|
9
9
|
},
|
|
10
10
|
"main": "src/index.ts",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"@testing-library/dom": "^10.4.1",
|
|
15
15
|
"@testing-library/svelte": "^5.4.2",
|
|
16
16
|
"@testing-library/user-event": "^14.6.6",
|
|
17
|
-
"@types/bun": "^1.4.
|
|
17
|
+
"@types/bun": "^1.4.2",
|
|
18
18
|
"@types/jsdom": "^30.0.0",
|
|
19
19
|
"happy-dom": "^20.11.12",
|
|
20
20
|
"jsdom": "^30.0.1",
|
|
@@ -25,6 +25,22 @@
|
|
|
25
25
|
"@tanstack/svelte-query": "^6.1.48"
|
|
26
26
|
},
|
|
27
27
|
"exports": {
|
|
28
|
+
"./schema": {
|
|
29
|
+
"types": "./src/schema.ts",
|
|
30
|
+
"default": "./src/schema.ts"
|
|
31
|
+
},
|
|
32
|
+
"./options": {
|
|
33
|
+
"types": "./src/defined-options.ts",
|
|
34
|
+
"default": "./src/defined-options.ts"
|
|
35
|
+
},
|
|
36
|
+
"./resource-contract": {
|
|
37
|
+
"types": "./src/resource-contract.ts",
|
|
38
|
+
"default": "./src/resource-contract.ts"
|
|
39
|
+
},
|
|
40
|
+
"./unsafe": {
|
|
41
|
+
"types": "./src/unsafe.ts",
|
|
42
|
+
"default": "./src/unsafe.ts"
|
|
43
|
+
},
|
|
28
44
|
".": {
|
|
29
45
|
"types": "./src/index.ts",
|
|
30
46
|
"default": "./src/index.ts"
|
|
@@ -91,7 +107,8 @@
|
|
|
91
107
|
"type": "module",
|
|
92
108
|
"types": "src/index.ts",
|
|
93
109
|
"dependencies": {
|
|
94
|
-
"@sinclair/typebox": "^0.34.52"
|
|
110
|
+
"@sinclair/typebox": "^0.34.52",
|
|
111
|
+
"papaparse": "^5.5.4"
|
|
95
112
|
},
|
|
96
113
|
"peerDependenciesMeta": {
|
|
97
114
|
"svelte": {
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { Type } from '@sinclair/typebox';
|
|
2
|
+
import { checkExact } from './schema-validation';
|
|
3
|
+
import { snapshotPlainData } from './plain-data';
|
|
4
|
+
import { HttpError } from './types';
|
|
5
|
+
import { permissionCatalogBindingSchema } from './permission-catalog';
|
|
6
|
+
import type {
|
|
7
|
+
CanParams, CanResult, AccessControlProvider, AccessControlOptions, RegisteredAccessControlProvider,
|
|
8
|
+
} from './permissions.svelte';
|
|
9
|
+
|
|
10
|
+
const request = Type.Object({
|
|
11
|
+
resource: Type.String({ minLength: 1 }),
|
|
12
|
+
action: Type.String({ minLength: 1 }),
|
|
13
|
+
params: Type.Optional(Type.Object({
|
|
14
|
+
id: Type.Optional(Type.Union([Type.String(), Type.Number()])),
|
|
15
|
+
}, { additionalProperties: Type.Unknown() })),
|
|
16
|
+
meta: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
17
|
+
}, { additionalProperties: false });
|
|
18
|
+
const response = Type.Object({
|
|
19
|
+
can: Type.Boolean(), reason: Type.Optional(Type.String()),
|
|
20
|
+
}, { additionalProperties: false });
|
|
21
|
+
const batchRequest = Type.Array(request);
|
|
22
|
+
const batchResponse = Type.Array(response);
|
|
23
|
+
const providerOptions = Type.Object({
|
|
24
|
+
buttons: Type.Optional(Type.Object({
|
|
25
|
+
enableAccessControl: Type.Optional(Type.Boolean()),
|
|
26
|
+
hideIfUnauthorized: Type.Optional(Type.Boolean()),
|
|
27
|
+
}, { additionalProperties: false })),
|
|
28
|
+
permissionCatalog: Type.Optional(permissionCatalogBindingSchema),
|
|
29
|
+
}, { additionalProperties: false });
|
|
30
|
+
const registeredProviders = new WeakMap<object, RegisteredAccessControlProvider>();
|
|
31
|
+
|
|
32
|
+
export function snapshotAccessControlOptions(value: unknown): AccessControlOptions {
|
|
33
|
+
try {
|
|
34
|
+
const candidate = snapshotPlainData(value);
|
|
35
|
+
if (checkExact(providerOptions, candidate)) {
|
|
36
|
+
if (candidate.buttons) Object.freeze(candidate.buttons);
|
|
37
|
+
if (candidate.permissionCatalog) {
|
|
38
|
+
if (candidate.permissionCatalog.digest !== undefined) {
|
|
39
|
+
candidate.permissionCatalog.digest = candidate.permissionCatalog.digest.toLowerCase();
|
|
40
|
+
}
|
|
41
|
+
Object.freeze(candidate.permissionCatalog);
|
|
42
|
+
}
|
|
43
|
+
return Object.freeze(candidate);
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
// Options are plain data, never executable configuration.
|
|
47
|
+
}
|
|
48
|
+
throw new HttpError('Invalid access control options', 422, undefined, { code: 'INVALID_ACCESS_CONTROL_OPTIONS' });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function invalidAccessControlProvider(): HttpError {
|
|
52
|
+
return new HttpError('Invalid access control provider', 502, undefined, { code: 'INVALID_ACCESS_CONTROL_PROVIDER' });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Class methods are supported, but property accessors and polluted Object.prototype are not. */
|
|
56
|
+
function providerMember(value: object, key: string): { readonly value: unknown } | undefined {
|
|
57
|
+
const seen = new Set<object>();
|
|
58
|
+
let target: object | null = value;
|
|
59
|
+
for (let depth = 0; target !== null && target !== Object.prototype && depth < 32; depth++) {
|
|
60
|
+
if (seen.has(target)) throw invalidAccessControlProvider();
|
|
61
|
+
seen.add(target);
|
|
62
|
+
const descriptor = Object.getOwnPropertyDescriptor(target, key);
|
|
63
|
+
if (descriptor) {
|
|
64
|
+
if (!('value' in descriptor)) throw invalidAccessControlProvider();
|
|
65
|
+
const field: unknown = descriptor.value;
|
|
66
|
+
return { value: field };
|
|
67
|
+
}
|
|
68
|
+
const parent: unknown = Object.getPrototypeOf(target);
|
|
69
|
+
if (parent !== null && typeof parent !== 'object') throw invalidAccessControlProvider();
|
|
70
|
+
target = parent;
|
|
71
|
+
}
|
|
72
|
+
if (target !== null && target !== Object.prototype) throw invalidAccessControlProvider();
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** One input identity owns one captured configuration; raw provider objects never become the public projection. */
|
|
77
|
+
export function captureAccessControlProvider(value: unknown): RegisteredAccessControlProvider {
|
|
78
|
+
try {
|
|
79
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) throw invalidAccessControlProvider();
|
|
80
|
+
const existing = registeredProviders.get(value);
|
|
81
|
+
if (existing) return existing;
|
|
82
|
+
const can = providerMember(value, 'can')?.value;
|
|
83
|
+
const batch = providerMember(value, 'canMany');
|
|
84
|
+
const canMany = batch?.value;
|
|
85
|
+
const config = providerMember(value, 'options');
|
|
86
|
+
if (typeof can !== 'function' || (batch !== undefined && typeof canMany !== 'function')) throw invalidAccessControlProvider();
|
|
87
|
+
const options = snapshotAccessControlOptions(config ? config.value : {});
|
|
88
|
+
const captured: RegisteredAccessControlProvider = Object.freeze({
|
|
89
|
+
options,
|
|
90
|
+
can: async (input: CanParams): Promise<CanResult> => {
|
|
91
|
+
try {
|
|
92
|
+
const request = snapshotCanParams(input);
|
|
93
|
+
const result: unknown = await Reflect.apply(can, value, [request]);
|
|
94
|
+
return decodeCanResult(result);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
throw accessControlFailure(error);
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
...(typeof canMany === 'function' ? {
|
|
100
|
+
canMany: async (input: readonly CanParams[]): Promise<CanResult[]> => {
|
|
101
|
+
try {
|
|
102
|
+
const requests = snapshotCanBatchParams(input);
|
|
103
|
+
const count = requests.length;
|
|
104
|
+
const result: unknown = await Reflect.apply(canMany, value, [requests]);
|
|
105
|
+
return decodeCanResults(result, count);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
throw accessControlFailure(error);
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
} : {}),
|
|
111
|
+
});
|
|
112
|
+
registeredProviders.set(value, captured);
|
|
113
|
+
registeredProviders.set(captured, captured);
|
|
114
|
+
return captured;
|
|
115
|
+
} catch (error) {
|
|
116
|
+
throw accessControlFailure(error);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function snapshotCanParams(value: unknown): CanParams {
|
|
121
|
+
try {
|
|
122
|
+
const candidate = snapshotPlainData(value);
|
|
123
|
+
if (checkExact(request, candidate)) return candidate;
|
|
124
|
+
} catch {
|
|
125
|
+
// Do not invoke accessors or serialization hooks from permission inputs.
|
|
126
|
+
}
|
|
127
|
+
throw new HttpError('Invalid access control request', 422, undefined, { code: 'INVALID_ACCESS_CONTROL_INPUT' });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function decodeCanResult(value: unknown): CanResult {
|
|
131
|
+
try {
|
|
132
|
+
const candidate = snapshotPlainData(value);
|
|
133
|
+
if (checkExact(response, candidate)) return candidate;
|
|
134
|
+
} catch {
|
|
135
|
+
// Malformed decisions must fail closed without exposing provider details.
|
|
136
|
+
}
|
|
137
|
+
throw new HttpError('Invalid access control response', 502, undefined, { code: 'INVALID_ACCESS_CONTROL_RESPONSE' });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function snapshotCanBatchParams(value: unknown): CanParams[] {
|
|
141
|
+
try {
|
|
142
|
+
const candidate = snapshotPlainData(value);
|
|
143
|
+
if (checkExact(batchRequest, candidate)) return candidate;
|
|
144
|
+
} catch {
|
|
145
|
+
// Validate the complete batch before invoking even its first capability.
|
|
146
|
+
}
|
|
147
|
+
throw new HttpError('Invalid access control request', 422, undefined, { code: 'INVALID_ACCESS_CONTROL_INPUT' });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function decodeCanResults(value: unknown, count: number): CanResult[] {
|
|
151
|
+
try {
|
|
152
|
+
const candidate = snapshotPlainData(value);
|
|
153
|
+
if (checkExact(batchResponse, candidate) && candidate.length === count) return candidate;
|
|
154
|
+
} catch {
|
|
155
|
+
// Never return partially validated batches.
|
|
156
|
+
}
|
|
157
|
+
throw new HttpError('Invalid access control response', 502, undefined, { code: 'INVALID_ACCESS_CONTROL_RESPONSE' });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function supersededAccessControl(): HttpError {
|
|
161
|
+
return new HttpError('Access control scope changed', 409, undefined, { code: 'ACCESS_CONTROL_SUPERSEDED' });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Preserve protocol status and checked codes, not provider diagnostics or getters. */
|
|
165
|
+
export function accessControlFailure(error: unknown): HttpError {
|
|
166
|
+
let status = 502;
|
|
167
|
+
let code = 'ACCESS_CONTROL_FAILED';
|
|
168
|
+
try {
|
|
169
|
+
if (typeof error === 'object' && error !== null) {
|
|
170
|
+
const statusValue: unknown = Object.getOwnPropertyDescriptor(error, 'statusCode')?.value;
|
|
171
|
+
const codeValue: unknown = Object.getOwnPropertyDescriptor(error, 'code')?.value;
|
|
172
|
+
if (typeof statusValue === 'number' && Number.isInteger(statusValue) && statusValue >= 400 && statusValue <= 599) status = statusValue;
|
|
173
|
+
if (codeValue === 'INVALID_ACCESS_CONTROL_INPUT' || codeValue === 'INVALID_ACCESS_CONTROL_RESPONSE' ||
|
|
174
|
+
codeValue === 'INVALID_ACCESS_CONTROL_PROVIDER' || codeValue === 'INVALID_ACCESS_CONTROL_OPTIONS' ||
|
|
175
|
+
codeValue === 'ACCESS_CONTROL_SUPERSEDED') code = codeValue;
|
|
176
|
+
}
|
|
177
|
+
} catch {
|
|
178
|
+
// Reflection failures cannot disclose private provider errors.
|
|
179
|
+
}
|
|
180
|
+
return new HttpError('Access control request failed', status, undefined, { code });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Capture a single-decision capability before dispatch and isolate every request. */
|
|
184
|
+
export function prepareCanCheck(provider: AccessControlProvider | null, params: CanParams): () => Promise<CanResult> {
|
|
185
|
+
const request = snapshotCanParams(params);
|
|
186
|
+
if (provider === null) return async () => ({ can: true });
|
|
187
|
+
try {
|
|
188
|
+
const can = provider.can;
|
|
189
|
+
if (typeof can === 'function') {
|
|
190
|
+
return async () => decodeCanResult(await can.call(provider, snapshotCanParams(request)));
|
|
191
|
+
}
|
|
192
|
+
} catch {
|
|
193
|
+
// Capability lookup failures become query errors, not render-time provider diagnostics.
|
|
194
|
+
}
|
|
195
|
+
return async () => {
|
|
196
|
+
throw new HttpError('Invalid access control provider', 502, undefined, { code: 'INVALID_ACCESS_CONTROL_PROVIDER' });
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function prepareCanBatchCheck(
|
|
201
|
+
provider: AccessControlProvider | null, params: readonly CanParams[], current: () => boolean = () => true,
|
|
202
|
+
): () => Promise<CanResult[]> {
|
|
203
|
+
const requests = snapshotCanBatchParams(params);
|
|
204
|
+
if (provider === null || requests.length === 0) return async () => requests.map(() => ({ can: true }));
|
|
205
|
+
try {
|
|
206
|
+
const canMany = provider.canMany;
|
|
207
|
+
if (typeof canMany === 'function') {
|
|
208
|
+
return async () => {
|
|
209
|
+
if (!current()) throw supersededAccessControl();
|
|
210
|
+
return decodeCanResults(await canMany.call(provider, snapshotCanBatchParams(requests)), requests.length);
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
const can = canMany === undefined ? provider.can : undefined;
|
|
214
|
+
if (typeof can === 'function') {
|
|
215
|
+
return async () => {
|
|
216
|
+
const settled = await Promise.allSettled(requests.map(async request => {
|
|
217
|
+
if (!current()) throw supersededAccessControl();
|
|
218
|
+
return decodeCanResult(await can.call(provider, snapshotCanParams(request)));
|
|
219
|
+
}));
|
|
220
|
+
const results: CanResult[] = [];
|
|
221
|
+
for (const result of settled) {
|
|
222
|
+
if (result.status === 'rejected') {
|
|
223
|
+
const error: unknown = result.reason;
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
results.push(result.value);
|
|
227
|
+
}
|
|
228
|
+
return results;
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
} catch {
|
|
232
|
+
// A malformed native batch capability must not silently fall back to single calls.
|
|
233
|
+
}
|
|
234
|
+
return async () => {
|
|
235
|
+
throw new HttpError('Invalid access control provider', 502, undefined, { code: 'INVALID_ACCESS_CONTROL_PROVIDER' });
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const sources = new WeakMap<AccessControlProvider, string>();
|
|
240
|
+
const runtime = `${Date.now()}:${Math.random().toString(36).slice(2)}`;
|
|
241
|
+
let next = 0;
|
|
242
|
+
export function accessControlSource(provider: AccessControlProvider | null): string {
|
|
243
|
+
if (!provider) return 'no-access-control-provider';
|
|
244
|
+
let source = sources.get(provider);
|
|
245
|
+
if (source === undefined) {
|
|
246
|
+
source = `${runtime}:${++next}`;
|
|
247
|
+
sources.set(provider, source);
|
|
248
|
+
}
|
|
249
|
+
return source;
|
|
250
|
+
}
|
package/src/adapters/casbin.ts
CHANGED
|
@@ -18,7 +18,10 @@
|
|
|
18
18
|
* ```
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import type { AccessControlProvider
|
|
21
|
+
import type { AccessControlProvider } from '../permissions.svelte';
|
|
22
|
+
import { definedOptions } from '../defined-options';
|
|
23
|
+
import { accessControlFailure, decodeCanResult, snapshotCanParams } from '../access-control-contract';
|
|
24
|
+
import { HttpError } from '../types';
|
|
22
25
|
|
|
23
26
|
/** Casbin Enforcer interface (minimal, to avoid hard dependency on casbin) */
|
|
24
27
|
interface CasbinEnforcer {
|
|
@@ -48,21 +51,24 @@ export function createCasbinAccessControl(
|
|
|
48
51
|
options: CasbinAdapterOptions,
|
|
49
52
|
): AccessControlProvider {
|
|
50
53
|
return {
|
|
51
|
-
can: async (
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
const
|
|
55
|
-
|
|
54
|
+
can: async (input) => {
|
|
55
|
+
try {
|
|
56
|
+
const { resource, action } = snapshotCanParams(input);
|
|
57
|
+
const user=options.getUser();
|
|
58
|
+
if (typeof user !== 'string') {
|
|
59
|
+
throw new HttpError('Invalid access control provider', 502, undefined, { code: 'INVALID_ACCESS_CONTROL_PROVIDER' });
|
|
60
|
+
}
|
|
61
|
+
const allowed=await enforcer.enforce(user,resource,action);
|
|
62
|
+
return decodeCanResult({
|
|
56
63
|
can: allowed,
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
64
|
+
...definedOptions({
|
|
65
|
+
reason: allowed? undefined:`User "${user}" cannot "${action}" on "${resource}"`,
|
|
66
|
+
}),
|
|
67
|
+
});
|
|
68
|
+
} catch (error) {
|
|
69
|
+
throw accessControlFailure(error);
|
|
63
70
|
}
|
|
64
|
-
return executeCheck(params);
|
|
65
71
|
},
|
|
66
|
-
options: options.providerOptions,
|
|
72
|
+
...definedOptions({ options: options.providerOptions }),
|
|
67
73
|
};
|
|
68
74
|
}
|
package/src/adapters/casl.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
1
|
/**
|
|
3
2
|
* CASL adapter for svadmin AccessControlProvider.
|
|
4
3
|
*
|
|
@@ -20,12 +19,15 @@
|
|
|
20
19
|
* ```
|
|
21
20
|
*/
|
|
22
21
|
|
|
23
|
-
import type { AccessControlProvider
|
|
22
|
+
import type { AccessControlProvider } from '../permissions.svelte';
|
|
23
|
+
import { definedOptions } from '../defined-options';
|
|
24
|
+
import { accessControlFailure, decodeCanResult, snapshotCanParams } from '../access-control-contract';
|
|
25
|
+
import { HttpError } from '../types';
|
|
24
26
|
|
|
25
27
|
/** CASL Ability interface (minimal, to avoid hard dependency on @casl/ability) */
|
|
26
28
|
interface CaslAbility {
|
|
27
|
-
can: (action: string,
|
|
28
|
-
cannot: (action: string,
|
|
29
|
+
can: (action: string,subject: string,field?: string) => boolean;
|
|
30
|
+
cannot: (action: string,subject: string,field?: string) => boolean;
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
/**
|
|
@@ -39,23 +41,24 @@ export function createCaslAccessControl(
|
|
|
39
41
|
options?: AccessControlProvider['options'],
|
|
40
42
|
): AccessControlProvider {
|
|
41
43
|
return {
|
|
42
|
-
can: async (
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
can: async (input) => {
|
|
45
|
+
try {
|
|
46
|
+
const { resource, action, params: actionParams } = snapshotCanParams(input);
|
|
47
|
+
const field=actionParams?.['field'];
|
|
48
|
+
if(field!==undefined&&typeof field!=='string') {
|
|
49
|
+
throw new HttpError('Invalid access control request', 422, undefined, { code: 'INVALID_ACCESS_CONTROL_INPUT' });
|
|
50
|
+
}
|
|
51
|
+
const allowed=ability.can(action,resource,field);
|
|
52
|
+
return decodeCanResult({
|
|
49
53
|
can: allowed,
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
...definedOptions({
|
|
55
|
+
reason: allowed? undefined:`Cannot "${action}" on "${resource}"${field? ` (field: ${field})`:''}`,
|
|
56
|
+
}),
|
|
57
|
+
});
|
|
58
|
+
} catch (error) {
|
|
59
|
+
throw accessControlFailure(error);
|
|
56
60
|
}
|
|
57
|
-
return executeCheck(params);
|
|
58
61
|
},
|
|
59
|
-
options,
|
|
62
|
+
...definedOptions({ options }),
|
|
60
63
|
};
|
|
61
64
|
}
|
package/src/admin-tool.ts
CHANGED
|
@@ -39,24 +39,24 @@ export interface AdminTool<Schema extends TObject = TObject> {
|
|
|
39
39
|
parameters: Schema;
|
|
40
40
|
/** Pause execution until the user explicitly approves the invocation. */
|
|
41
41
|
needsApproval?: boolean;
|
|
42
|
-
/**
|
|
42
|
+
/** Declares whether the operation is read-only and safe to execute concurrently. */
|
|
43
43
|
readOnly?: boolean;
|
|
44
|
-
/**
|
|
44
|
+
/** Declares whether the operation is destructive or high risk. */
|
|
45
45
|
destructive?: boolean;
|
|
46
|
-
/**
|
|
46
|
+
/** Declares whether concurrent calls are supported. */
|
|
47
47
|
concurrent?: boolean;
|
|
48
48
|
/** Execute the tool with decoded arguments. */
|
|
49
49
|
execute(args: StaticDecode<Schema>): Promise<ToolResult>;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
/**
|
|
52
|
+
/** Preserves the concrete TypeBox schema so execute receives precise static types. */
|
|
53
53
|
export function defineAdminTool<const Schema extends TObject>(
|
|
54
54
|
tool: AdminTool<Schema>,
|
|
55
55
|
): AdminTool<Schema> {
|
|
56
56
|
return { ...tool, parameters: strictAdminToolSchema(tool.parameters) };
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
/**
|
|
59
|
+
/** Applies TypeBox validation and conversion before invoking the tool implementation. */
|
|
60
60
|
export function decodeAdminToolArgs<const Schema extends TObject>(
|
|
61
61
|
tool: AdminTool<Schema>,
|
|
62
62
|
input: unknown,
|
|
@@ -64,7 +64,7 @@ export function decodeAdminToolArgs<const Schema extends TObject>(
|
|
|
64
64
|
return Value.Decode(strictAdminToolSchema(tool.parameters), input);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
/**
|
|
67
|
+
/** Decodes and executes a tool without allowing callers to bypass runtime input validation. */
|
|
68
68
|
export async function executeAdminTool<const Schema extends TObject>(
|
|
69
69
|
tool: AdminTool<Schema>,
|
|
70
70
|
input: unknown,
|
|
@@ -72,7 +72,7 @@ export async function executeAdminTool<const Schema extends TObject>(
|
|
|
72
72
|
return tool.execute(decodeAdminToolArgs(tool, input));
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
/**
|
|
75
|
+
/** Projects a tool into the public schema sent to LLM or MCP clients. */
|
|
76
76
|
export function projectAdminToolSchema<Schema extends TObject>(tool: AdminTool<Schema>): {
|
|
77
77
|
name: string;
|
|
78
78
|
description: string;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { Type, type Static, type TSchema } from '@sinclair/typebox';
|
|
2
|
+
import { checkExact } from './schema-validation';
|
|
3
|
+
import { snapshotPlainData } from './plain-data';
|
|
4
|
+
|
|
5
|
+
const text = Type.String({ minLength: 1 });
|
|
6
|
+
const record = Type.Record(Type.String(), Type.Unknown());
|
|
7
|
+
const identifier = Type.Union([
|
|
8
|
+
text, Type.Integer({ minimum: Number.MIN_SAFE_INTEGER, maximum: Number.MAX_SAFE_INTEGER }),
|
|
9
|
+
]);
|
|
10
|
+
const entrySchema = Type.Object({
|
|
11
|
+
id: Type.Optional(identifier),
|
|
12
|
+
timestamp: text,
|
|
13
|
+
action: text,
|
|
14
|
+
mutationId: Type.Optional(text),
|
|
15
|
+
resource: Type.Optional(text),
|
|
16
|
+
recordId: Type.Optional(identifier),
|
|
17
|
+
userId: Type.Optional(text),
|
|
18
|
+
details: Type.Optional(record),
|
|
19
|
+
data: Type.Optional(record),
|
|
20
|
+
previousData: Type.Optional(record),
|
|
21
|
+
meta: Type.Optional(record),
|
|
22
|
+
outcome: Type.Optional(Type.Union([Type.Literal('success'), Type.Literal('failure')])),
|
|
23
|
+
tenantId: Type.Optional(identifier),
|
|
24
|
+
requestId: Type.Optional(text),
|
|
25
|
+
traceId: Type.Optional(text),
|
|
26
|
+
ipAddress: Type.Optional(text),
|
|
27
|
+
userAgent: Type.Optional(text),
|
|
28
|
+
error: Type.Optional(Type.Object({ message: Type.String(), code: Type.Optional(text) }, { additionalProperties: false })),
|
|
29
|
+
}, { additionalProperties: false });
|
|
30
|
+
const draftSchema = Type.Omit(entrySchema, ['id', 'timestamp']);
|
|
31
|
+
const createSchema = Type.Omit(entrySchema, ['id']);
|
|
32
|
+
const querySchema = Type.Object({
|
|
33
|
+
resource: Type.Optional(text), action: Type.Optional(text),
|
|
34
|
+
meta: Type.Optional(record), author: Type.Optional(record),
|
|
35
|
+
}, { additionalProperties: false });
|
|
36
|
+
const entriesSchema = Type.Array(entrySchema);
|
|
37
|
+
|
|
38
|
+
export type AuditEntry = Static<typeof entrySchema>;
|
|
39
|
+
export type AuditDraft = Static<typeof draftSchema>;
|
|
40
|
+
export type AuditCreateParams = Static<typeof createSchema>;
|
|
41
|
+
export type AuditQueryParams = Static<typeof querySchema>;
|
|
42
|
+
|
|
43
|
+
const messages = {
|
|
44
|
+
INVALID_AUDIT_INPUT: 'Invalid audit input.',
|
|
45
|
+
INVALID_AUDIT_RESPONSE: 'Invalid audit response.',
|
|
46
|
+
AUDIT_HANDLER_FAILED: 'Audit handler write could not be confirmed.',
|
|
47
|
+
AUDIT_PROVIDER_FAILED: 'Audit provider request failed.',
|
|
48
|
+
AUDIT_PROVIDER_REQUIRED: 'Strict audit logging requires an AuditLogProvider.',
|
|
49
|
+
} as const;
|
|
50
|
+
|
|
51
|
+
export class AuditError extends Error {
|
|
52
|
+
constructor(readonly code: keyof typeof messages, readonly writeMayHaveSucceeded = false) {
|
|
53
|
+
super(messages[code]);
|
|
54
|
+
this.name = 'AuditError';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function decode<S extends TSchema>(
|
|
59
|
+
schema: S, value: unknown, phase: 'input' | 'response', write = false,
|
|
60
|
+
): Static<S> {
|
|
61
|
+
try {
|
|
62
|
+
const candidate = snapshotPlainData(value);
|
|
63
|
+
if (checkExact(schema, candidate)) return candidate;
|
|
64
|
+
} catch {
|
|
65
|
+
// Never retain the submitted record or a reflection exception.
|
|
66
|
+
}
|
|
67
|
+
throw new AuditError(phase === 'input' ? 'INVALID_AUDIT_INPUT' : 'INVALID_AUDIT_RESPONSE', write);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Audit timestamps are UTC ISO strings, with seconds or exactly three fractional digits. */
|
|
71
|
+
function validateTimestamp(value: string, phase: 'input' | 'response', write: boolean): void {
|
|
72
|
+
const date = new Date(value);
|
|
73
|
+
if (Number.isFinite(date.getTime())) {
|
|
74
|
+
const iso = date.toISOString();
|
|
75
|
+
if (iso === value || (iso.endsWith('.000Z') && iso.replace('.000Z', 'Z') === value)) return;
|
|
76
|
+
}
|
|
77
|
+
throw new AuditError(phase === 'input' ? 'INVALID_AUDIT_INPUT' : 'INVALID_AUDIT_RESPONSE', write);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function decodeAuditEntry(value: unknown, phase: 'input' | 'response' = 'input', write = false): AuditEntry {
|
|
81
|
+
const entry = decode(entrySchema, value, phase, write);
|
|
82
|
+
validateTimestamp(entry.timestamp, phase, write);
|
|
83
|
+
return entry;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function createAuditEntry(value: unknown): AuditEntry {
|
|
87
|
+
const draft = decode(draftSchema, value, 'input');
|
|
88
|
+
return { ...draft, timestamp: new Date().toISOString() };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function decodeAuditCreate(value: unknown): AuditCreateParams {
|
|
92
|
+
const entry = decode(createSchema, value, 'input');
|
|
93
|
+
validateTimestamp(entry.timestamp, 'input', false);
|
|
94
|
+
return entry;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function decodeAuditQuery(value: unknown): AuditQueryParams {
|
|
98
|
+
return decode(querySchema, value, 'input');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function decodeAuditEntries(value: unknown): AuditEntry[] {
|
|
102
|
+
const entries = decode(entriesSchema, value, 'response');
|
|
103
|
+
for (const entry of entries) validateTimestamp(entry.timestamp, 'response', false);
|
|
104
|
+
return entries;
|
|
105
|
+
}
|