@object-ui/data-objectstack 17.3.0 → 17.6.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/CHANGELOG.md +888 -0
- package/README.md +3 -11
- package/dist/index.cjs +1511 -1115
- package/dist/index.d.cts +1222 -824
- package/dist/index.d.ts +1222 -824
- package/dist/index.js +1503 -1111
- package/package.json +5 -6
- package/src/aggregate-capability.test.ts +0 -177
- package/src/bulkDelete.test.ts +0 -75
- package/src/bulkUpdate.test.ts +0 -93
- package/src/cache/MetadataCache.test.ts +0 -477
- package/src/cache/MetadataCache.ts +0 -289
- package/src/cloud.ts +0 -109
- package/src/connection.test.ts +0 -188
- package/src/contracts.ts +0 -115
- package/src/errors.test.ts +0 -426
- package/src/errors.ts +0 -293
- package/src/exportDownload.test.ts +0 -141
- package/src/filter-entry-translation.test.ts +0 -360
- package/src/filter-operator-ast-parity.test.ts +0 -71
- package/src/getObjectSchema.test.ts +0 -145
- package/src/importJob.test.ts +0 -71
- package/src/index.ts +0 -3658
- package/src/integration.ts +0 -200
- package/src/listViews.test.ts +0 -175
- package/src/metadata-client.test.ts +0 -303
- package/src/metadata-client.ts +0 -849
- package/src/occ.test.ts +0 -101
- package/src/onMutation.test.ts +0 -562
- package/src/onWriteWarning.test.ts +0 -273
- package/src/orderby-serialization.test.ts +0 -115
- package/src/queryDataset.test.ts +0 -132
- package/src/searchAll.test.ts +0 -120
- package/src/security.ts +0 -243
- package/src/spec-symbol-batch6.test.ts +0 -306
- package/src/studio.ts +0 -152
- package/src/userState.test.ts +0 -315
- package/src/userState.ts +0 -238
- package/src/v3-compat.test.ts +0 -240
- package/src/validation-error.test.ts +0 -94
package/src/security.ts
DELETED
|
@@ -1,243 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ObjectUI
|
|
3
|
-
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
-
*
|
|
5
|
-
* This source code is licensed under the MIT license found in the
|
|
6
|
-
* LICENSE file in the root directory of this source tree.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Security module integration for @objectstack/spec v3.0.0
|
|
11
|
-
* Provides advanced security policies: CSP config, audit logging, data masking.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Configuration for {@link SecurityManager} — this adapter's browser-side
|
|
16
|
-
* security posture: the CSP header it generates, the in-memory audit log it
|
|
17
|
-
* keeps, and the field-masking rules it applies before a record is rendered.
|
|
18
|
-
*
|
|
19
|
-
* NOT the spec's `SecurityPolicy` (`@objectstack/spec/kernel`), whose name this
|
|
20
|
-
* interface wore until objectui#3160 (objectstack#4115 ledger batch 6). That one
|
|
21
|
-
* is the PACKAGE SUPPLY-CHAIN policy — `{ id, name, autoScan, thresholds,
|
|
22
|
-
* allowedLicenses, prohibitedLicenses, codeSigning, sandbox }` — the rules a
|
|
23
|
-
* marketplace applies when scanning a plugin before install. It shares no key
|
|
24
|
-
* with this one, so there was nothing to derive; the collision is the word
|
|
25
|
-
* "security", not the concept.
|
|
26
|
-
*/
|
|
27
|
-
export interface SecurityManagerPolicy {
|
|
28
|
-
/** Content Security Policy configuration */
|
|
29
|
-
csp?: CSPConfig;
|
|
30
|
-
/** Audit logging configuration */
|
|
31
|
-
auditLog?: AuditLogConfig;
|
|
32
|
-
/** Data masking rules */
|
|
33
|
-
dataMasking?: DataMaskingConfig;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface CSPConfig {
|
|
37
|
-
/** Script sources */
|
|
38
|
-
scriptSrc: string[];
|
|
39
|
-
/** Style sources */
|
|
40
|
-
styleSrc: string[];
|
|
41
|
-
/** Image sources */
|
|
42
|
-
imgSrc: string[];
|
|
43
|
-
/** Connect sources (APIs, WebSockets) */
|
|
44
|
-
connectSrc: string[];
|
|
45
|
-
/** Font sources */
|
|
46
|
-
fontSrc: string[];
|
|
47
|
-
/** Frame sources */
|
|
48
|
-
frameSrc?: string[];
|
|
49
|
-
/** Report URI for violations */
|
|
50
|
-
reportUri?: string;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export interface AuditLogConfig {
|
|
54
|
-
/** Whether audit logging is enabled */
|
|
55
|
-
enabled: boolean;
|
|
56
|
-
/** Events to audit */
|
|
57
|
-
events: AuditEventType[];
|
|
58
|
-
/** Log retention days */
|
|
59
|
-
retentionDays?: number;
|
|
60
|
-
/** External log destination */
|
|
61
|
-
destination?: 'console' | 'server' | 'both';
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export type AuditEventType =
|
|
65
|
-
| 'auth.login'
|
|
66
|
-
| 'auth.logout'
|
|
67
|
-
| 'auth.failed'
|
|
68
|
-
| 'data.create'
|
|
69
|
-
| 'data.read'
|
|
70
|
-
| 'data.update'
|
|
71
|
-
| 'data.delete'
|
|
72
|
-
| 'admin.config'
|
|
73
|
-
| 'admin.user'
|
|
74
|
-
| 'admin.role';
|
|
75
|
-
|
|
76
|
-
export interface DataMaskingConfig {
|
|
77
|
-
/** Fields to mask */
|
|
78
|
-
rules: DataMaskingRule[];
|
|
79
|
-
/** Default masking character */
|
|
80
|
-
maskChar?: string;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export interface DataMaskingRule {
|
|
84
|
-
/** Field name or pattern */
|
|
85
|
-
field: string;
|
|
86
|
-
/** Masking strategy */
|
|
87
|
-
strategy: 'full' | 'partial' | 'hash' | 'redact';
|
|
88
|
-
/** Number of visible characters (for partial masking) */
|
|
89
|
-
visibleChars?: number;
|
|
90
|
-
/** Roles that can see unmasked data */
|
|
91
|
-
exemptRoles?: string[];
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
export interface AuditLogEntry {
|
|
95
|
-
/** Event timestamp */
|
|
96
|
-
timestamp: string;
|
|
97
|
-
/** Event type */
|
|
98
|
-
event: AuditEventType;
|
|
99
|
-
/** User who triggered the event */
|
|
100
|
-
userId: string;
|
|
101
|
-
/** Target resource */
|
|
102
|
-
resource?: string;
|
|
103
|
-
/** Record ID */
|
|
104
|
-
recordId?: string;
|
|
105
|
-
/** Additional details */
|
|
106
|
-
details?: Record<string, unknown>;
|
|
107
|
-
/** IP address */
|
|
108
|
-
ipAddress?: string;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Security policy manager.
|
|
113
|
-
* Handles CSP generation, audit logging, and data masking.
|
|
114
|
-
*/
|
|
115
|
-
export class SecurityManager {
|
|
116
|
-
private policy: SecurityManagerPolicy;
|
|
117
|
-
private auditLog: AuditLogEntry[] = [];
|
|
118
|
-
|
|
119
|
-
constructor(policy: SecurityManagerPolicy = {}) {
|
|
120
|
-
this.policy = policy;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Generate a CSP header string from the configuration.
|
|
125
|
-
*/
|
|
126
|
-
generateCSPHeader(): string {
|
|
127
|
-
const csp = this.policy.csp;
|
|
128
|
-
if (!csp) return '';
|
|
129
|
-
|
|
130
|
-
const directives: string[] = [];
|
|
131
|
-
|
|
132
|
-
if (csp.scriptSrc?.length) directives.push(`script-src ${csp.scriptSrc.join(' ')}`);
|
|
133
|
-
if (csp.styleSrc?.length) directives.push(`style-src ${csp.styleSrc.join(' ')}`);
|
|
134
|
-
if (csp.imgSrc?.length) directives.push(`img-src ${csp.imgSrc.join(' ')}`);
|
|
135
|
-
if (csp.connectSrc?.length) directives.push(`connect-src ${csp.connectSrc.join(' ')}`);
|
|
136
|
-
if (csp.fontSrc?.length) directives.push(`font-src ${csp.fontSrc.join(' ')}`);
|
|
137
|
-
if (csp.frameSrc?.length) directives.push(`frame-src ${csp.frameSrc.join(' ')}`);
|
|
138
|
-
if (csp.reportUri) directives.push(`report-uri ${csp.reportUri}`);
|
|
139
|
-
|
|
140
|
-
return directives.join('; ');
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Record an audit log entry.
|
|
145
|
-
*/
|
|
146
|
-
recordAudit(entry: Omit<AuditLogEntry, 'timestamp'>): void {
|
|
147
|
-
if (!this.policy.auditLog?.enabled) return;
|
|
148
|
-
if (this.policy.auditLog.events && !this.policy.auditLog.events.includes(entry.event)) return;
|
|
149
|
-
|
|
150
|
-
const fullEntry: AuditLogEntry = {
|
|
151
|
-
...entry,
|
|
152
|
-
timestamp: new Date().toISOString(),
|
|
153
|
-
};
|
|
154
|
-
|
|
155
|
-
this.auditLog.push(fullEntry);
|
|
156
|
-
|
|
157
|
-
const dest = this.policy.auditLog.destination ?? 'console';
|
|
158
|
-
if (dest === 'console' || dest === 'both') {
|
|
159
|
-
console.info('[AUDIT]', fullEntry.event, fullEntry.userId, fullEntry.resource ?? '');
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* Get audit log entries.
|
|
165
|
-
*/
|
|
166
|
-
getAuditLog(filter?: { event?: AuditEventType; userId?: string; since?: string }): AuditLogEntry[] {
|
|
167
|
-
let entries = [...this.auditLog];
|
|
168
|
-
if (filter?.event) entries = entries.filter(e => e.event === filter.event);
|
|
169
|
-
if (filter?.userId) entries = entries.filter(e => e.userId === filter.userId);
|
|
170
|
-
if (filter?.since) entries = entries.filter(e => e.timestamp >= filter.since!);
|
|
171
|
-
return entries;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* Apply data masking to a record.
|
|
176
|
-
*/
|
|
177
|
-
maskRecord(record: Record<string, unknown>, userRoles: string[] = []): Record<string, unknown> {
|
|
178
|
-
if (!this.policy.dataMasking?.rules?.length) return record;
|
|
179
|
-
|
|
180
|
-
const masked = { ...record };
|
|
181
|
-
const maskChar = this.policy.dataMasking.maskChar ?? '*';
|
|
182
|
-
|
|
183
|
-
for (const rule of this.policy.dataMasking.rules) {
|
|
184
|
-
if (!(rule.field in masked) || masked[rule.field] == null) continue;
|
|
185
|
-
|
|
186
|
-
// Check role exemptions
|
|
187
|
-
if (rule.exemptRoles?.some(role => userRoles.includes(role))) continue;
|
|
188
|
-
|
|
189
|
-
const value = String(masked[rule.field]);
|
|
190
|
-
|
|
191
|
-
switch (rule.strategy) {
|
|
192
|
-
case 'full':
|
|
193
|
-
masked[rule.field] = maskChar.repeat(value.length);
|
|
194
|
-
break;
|
|
195
|
-
case 'partial': {
|
|
196
|
-
const visible = rule.visibleChars ?? 4;
|
|
197
|
-
// Values shorter than visibleChars are fully masked for security
|
|
198
|
-
if (value.length <= visible) {
|
|
199
|
-
masked[rule.field] = maskChar.repeat(value.length);
|
|
200
|
-
} else {
|
|
201
|
-
masked[rule.field] = value.slice(0, visible) + maskChar.repeat(value.length - visible);
|
|
202
|
-
}
|
|
203
|
-
break;
|
|
204
|
-
}
|
|
205
|
-
case 'hash':
|
|
206
|
-
masked[rule.field] = `[HASHED:${simpleHash(value)}]`;
|
|
207
|
-
break;
|
|
208
|
-
case 'redact':
|
|
209
|
-
masked[rule.field] = '[REDACTED]';
|
|
210
|
-
break;
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
return masked;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
/**
|
|
218
|
-
* Update the security policy.
|
|
219
|
-
*/
|
|
220
|
-
updatePolicy(policy: Partial<SecurityManagerPolicy>): void {
|
|
221
|
-
this.policy = { ...this.policy, ...policy };
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/**
|
|
225
|
-
* Get current security policy.
|
|
226
|
-
*/
|
|
227
|
-
getPolicy(): SecurityManagerPolicy {
|
|
228
|
-
return { ...this.policy };
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/**
|
|
233
|
-
* Simple hash function for data masking (not cryptographic).
|
|
234
|
-
*/
|
|
235
|
-
function simpleHash(str: string): string {
|
|
236
|
-
let hash = 0;
|
|
237
|
-
for (let i = 0; i < str.length; i++) {
|
|
238
|
-
const char = str.charCodeAt(i);
|
|
239
|
-
hash = ((hash << 5) - hash) + char;
|
|
240
|
-
hash = hash & hash; // Convert to 32-bit integer
|
|
241
|
-
}
|
|
242
|
-
return Math.abs(hash).toString(36);
|
|
243
|
-
}
|
|
@@ -1,306 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ObjectUI
|
|
3
|
-
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
-
*
|
|
5
|
-
* This source code is licensed under the MIT license found in the
|
|
6
|
-
* LICENSE file in the root directory of this source tree.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* `@object-ui/data-objectstack` ↔ `@objectstack/spec` symbol-collision guards
|
|
11
|
-
* (objectui#3160, objectstack#4115 ledger batch 6).
|
|
12
|
-
*
|
|
13
|
-
* Five symbols here wore names the spec already owns. One really WAS the spec's
|
|
14
|
-
* and now carries its binding (`DroppedFieldsEvent`); the other four model
|
|
15
|
-
* something the spec has never modelled under that name and were renamed:
|
|
16
|
-
*
|
|
17
|
-
* CacheStats → MetadataCacheStats
|
|
18
|
-
* MetadataSaveOptions → MetadataClientSaveOptions
|
|
19
|
-
* SecurityPolicy → SecurityManagerPolicy
|
|
20
|
-
* ValidationError → DataApiValidationError
|
|
21
|
-
*
|
|
22
|
-
* The batch's own triage note said "data-objectstack is a direct client of the
|
|
23
|
-
* spec protocol, so `SecurityPolicy` / `DroppedFieldsEvent` are probably hand
|
|
24
|
-
* copies — derive them first". Half of that held: `DroppedFieldsEvent` was
|
|
25
|
-
* exactly a hand copy, and `SecurityPolicy` turned out to share not one key with
|
|
26
|
-
* the spec's. That asymmetry is why every assertion below is per SYMBOL and
|
|
27
|
-
* never per cluster.
|
|
28
|
-
*
|
|
29
|
-
* ## Why the spec's names are read through the compiler, not `import * as`
|
|
30
|
-
*
|
|
31
|
-
* A runtime namespace import sees VALUES only, and every name checked here is a
|
|
32
|
-
* TYPE; a tripwire built on `Object.keys(await import(…))` would pass while
|
|
33
|
-
* proving nothing. So this reads each subpath's `.d.ts` through the TypeScript
|
|
34
|
-
* checker, exactly as `scripts/check-spec-symbol-derivation.mjs` does.
|
|
35
|
-
*
|
|
36
|
-
* ## …and why through `ts.sys` rather than `node:fs`
|
|
37
|
-
*
|
|
38
|
-
* Unlike the sibling batches' parity tests, this package's `tsconfig.json`
|
|
39
|
-
* compiles its whole test tree (it is not in `check-type-check-coverage.mjs`'s
|
|
40
|
-
* TEST_DEBT), so the pins below are already checked by `tsc --noEmit` with no
|
|
41
|
-
* separate `tsconfig.typetests.json`. Keeping that property means NOT pulling
|
|
42
|
-
* `@types/node` into a browser-side adapter's type environment just to read a
|
|
43
|
-
* file — so the probe uses the TypeScript compiler's own host, which is typed
|
|
44
|
-
* by the `typescript` dependency this file already has, and resolves the spec
|
|
45
|
-
* through TS's module resolver instead of hard-coding its subpath list.
|
|
46
|
-
*/
|
|
47
|
-
|
|
48
|
-
import { describe, it, expect } from 'vitest';
|
|
49
|
-
import ts from 'typescript';
|
|
50
|
-
|
|
51
|
-
import { DataApiValidationError } from './errors';
|
|
52
|
-
import type { MetadataCacheStats } from './cache/MetadataCache';
|
|
53
|
-
import type { MetadataClientSaveOptions } from './metadata-client';
|
|
54
|
-
import type { SecurityManagerPolicy } from './security';
|
|
55
|
-
import type { DroppedFieldsEvent } from './index';
|
|
56
|
-
|
|
57
|
-
import type { DroppedFieldsEvent as SpecDroppedFieldsEvent } from '@objectstack/spec/data';
|
|
58
|
-
import type { CacheStats as SpecCacheStats } from '@objectstack/spec/contracts';
|
|
59
|
-
import type {
|
|
60
|
-
SecurityPolicy as SpecSecurityPolicy,
|
|
61
|
-
ValidationError as SpecValidationError,
|
|
62
|
-
} from '@objectstack/spec/kernel';
|
|
63
|
-
// `MetadataSaveOptions` moved `./kernel` → `./system` in spec 17.0.0-rc.2.
|
|
64
|
-
// The name still belongs to the spec and still means the FILE writer, so the
|
|
65
|
-
// rename this batch recorded stands — only the entry point moved.
|
|
66
|
-
import type { MetadataSaveOptions as SpecMetadataSaveOptions } from '@objectstack/spec/system';
|
|
67
|
-
|
|
68
|
-
const PROBE_OPTIONS: ts.CompilerOptions = {
|
|
69
|
-
noEmit: true,
|
|
70
|
-
skipLibCheck: true,
|
|
71
|
-
strict: false,
|
|
72
|
-
target: ts.ScriptTarget.ESNext,
|
|
73
|
-
module: ts.ModuleKind.ESNext,
|
|
74
|
-
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
75
|
-
resolveJsonModule: true,
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
/** This file, as TypeScript's module resolver wants it: an absolute path. */
|
|
79
|
-
const HERE = new URL(import.meta.url).pathname;
|
|
80
|
-
|
|
81
|
-
/** Every name `@objectstack/spec` exports from any subpath — types AND values. */
|
|
82
|
-
function specExportNames(): Set<string> {
|
|
83
|
-
const pkgPath = ts.resolveModuleName('@objectstack/spec/package.json', HERE, PROBE_OPTIONS, ts.sys)
|
|
84
|
-
.resolvedModule?.resolvedFileName;
|
|
85
|
-
if (!pkgPath) {
|
|
86
|
-
throw new Error('cannot resolve @objectstack/spec — run `pnpm install` first');
|
|
87
|
-
}
|
|
88
|
-
const pkgDir = pkgPath.slice(0, pkgPath.lastIndexOf('/'));
|
|
89
|
-
const raw = ts.sys.readFile(pkgPath);
|
|
90
|
-
if (!raw) throw new Error(`cannot read ${pkgPath}`);
|
|
91
|
-
const pkg = JSON.parse(raw) as {
|
|
92
|
-
exports?: Record<string, { import?: { types?: string }; require?: { types?: string } }>;
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
// Read the export map rather than a hand-written subpath list: a list is a
|
|
96
|
-
// copy, and a copy of the spec's shape is what this whole ledger is about.
|
|
97
|
-
const files: string[] = [];
|
|
98
|
-
for (const cond of Object.values(pkg.exports ?? {})) {
|
|
99
|
-
if (typeof cond !== 'object' || cond === null) continue;
|
|
100
|
-
const dts = cond.import?.types ?? cond.require?.types;
|
|
101
|
-
if (dts) files.push(ts.sys.resolvePath(`${pkgDir}/${dts.replace(/^\.\//, '')}`));
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const program = ts.createProgram(files, PROBE_OPTIONS);
|
|
105
|
-
const checker = program.getTypeChecker();
|
|
106
|
-
|
|
107
|
-
const names = new Set<string>();
|
|
108
|
-
for (const file of files) {
|
|
109
|
-
const sf = program.getSourceFile(file);
|
|
110
|
-
if (!sf) continue;
|
|
111
|
-
const moduleSymbol = checker.getSymbolAtLocation(sf);
|
|
112
|
-
if (!moduleSymbol) continue;
|
|
113
|
-
for (const exported of checker.getExportsOfModule(moduleSymbol)) names.add(exported.getName());
|
|
114
|
-
}
|
|
115
|
-
return names;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const SPEC_NAMES = specExportNames();
|
|
119
|
-
|
|
120
|
-
describe('the spec export-name probe itself works', () => {
|
|
121
|
-
it('reads a non-trivial number of names', () => {
|
|
122
|
-
expect(SPEC_NAMES.size).toBeGreaterThan(1000);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it('sees TYPE-only exports, not just runtime values', () => {
|
|
126
|
-
// `DroppedFieldsEvent` is a type alias — invisible to a runtime `import()`.
|
|
127
|
-
expect(SPEC_NAMES.has('DroppedFieldsEvent')).toBe(true);
|
|
128
|
-
});
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
const RENAMES: Array<[local: string, formerly: string, specMeaning: string]> = [
|
|
132
|
-
[
|
|
133
|
-
'MetadataCacheStats',
|
|
134
|
-
'CacheStats',
|
|
135
|
-
"the platform's ICacheService counters (keyCount / memoryUsage)",
|
|
136
|
-
],
|
|
137
|
-
[
|
|
138
|
-
'MetadataClientSaveOptions',
|
|
139
|
-
'MetadataSaveOptions',
|
|
140
|
-
'options for writing a metadata item to a FILE (format / path / indent / atomic)',
|
|
141
|
-
],
|
|
142
|
-
[
|
|
143
|
-
'SecurityManagerPolicy',
|
|
144
|
-
'SecurityPolicy',
|
|
145
|
-
'the package supply-chain policy (autoScan / licences / codeSigning / sandbox)',
|
|
146
|
-
],
|
|
147
|
-
[
|
|
148
|
-
'DataApiValidationError',
|
|
149
|
-
'ValidationError',
|
|
150
|
-
'a plain { field, message, code? } entry in a validation report',
|
|
151
|
-
],
|
|
152
|
-
];
|
|
153
|
-
|
|
154
|
-
describe('renamed local concepts do not collide with a spec export', () => {
|
|
155
|
-
it.each(RENAMES)('the spec does not own `%s`', (local) => {
|
|
156
|
-
expect(
|
|
157
|
-
SPEC_NAMES.has(local),
|
|
158
|
-
`@objectstack/spec now exports \`${local}\`. This package declares its own ` +
|
|
159
|
-
`\`${local}\`, so the rename that fixed objectstack#4115 has re-created the ` +
|
|
160
|
-
`collision under the new name. Rename again — and check the new name here ` +
|
|
161
|
-
`FIRST: objectui#3074 landed a rename straight onto another spec export, and ` +
|
|
162
|
-
`batch 4 burned two obvious candidates (SchemaValidationResult, ` +
|
|
163
|
-
`SchemaValidationReport) the same way.`,
|
|
164
|
-
).toBe(false);
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
it.each(RENAMES)('the spec still owns `%s` (it means: %s)', (_local, formerly) => {
|
|
168
|
-
expect(
|
|
169
|
-
SPEC_NAMES.has(formerly),
|
|
170
|
-
`@objectstack/spec no longer exports \`${formerly}\`, which is the only reason ` +
|
|
171
|
-
`this package renamed it. Either the spec dropped the name (then the plain ` +
|
|
172
|
-
`name can be taken back) or it moved (then re-read what it means now). A ` +
|
|
173
|
-
`workaround must not outlive its reason.`,
|
|
174
|
-
).toBe(true);
|
|
175
|
-
});
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
/* -------------------------------------------------------------------------- */
|
|
179
|
-
/* Compile-time pins. A violation is a `tsc` error, not a runtime failure. */
|
|
180
|
-
/* Compiled by this package's own `tsc --noEmit`: unlike its siblings, this */
|
|
181
|
-
/* package's tsconfig.json does NOT exclude test files, so no separate */
|
|
182
|
-
/* tsconfig.typetests.json is needed to make these pins load-bearing. */
|
|
183
|
-
/* -------------------------------------------------------------------------- */
|
|
184
|
-
|
|
185
|
-
type Assert<T extends true> = T;
|
|
186
|
-
type IsAny<T> = 0 extends 1 & T ? true : false;
|
|
187
|
-
/** The `unknown` erasure the `any` probe reports `false` for (objectui#3155). */
|
|
188
|
-
type IsUnknown<T> = [unknown] extends [T] ? ([T] extends [unknown] ? true : false) : false;
|
|
189
|
-
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
|
|
190
|
-
? true
|
|
191
|
-
: false;
|
|
192
|
-
type HasKey<T, K extends PropertyKey> = K extends keyof T ? true : false;
|
|
193
|
-
|
|
194
|
-
describe('DroppedFieldsEvent IS the spec type, not a mirror of it', () => {
|
|
195
|
-
it('is pinned at compile time', () => {
|
|
196
|
-
type _SpecNotAny = Assert<Equal<IsAny<SpecDroppedFieldsEvent>, false>>;
|
|
197
|
-
type _SpecNotUnknown = Assert<Equal<IsUnknown<SpecDroppedFieldsEvent>, false>>;
|
|
198
|
-
|
|
199
|
-
type _IsTheSpecType = Assert<Equal<DroppedFieldsEvent, SpecDroppedFieldsEvent>>;
|
|
200
|
-
|
|
201
|
-
// The pin that matters. The deleted copy widened `reason` to bare `string`
|
|
202
|
-
// "for forward-compatibility with reasons added server-side" — which is the
|
|
203
|
-
// AGENTS.md #12 lenient fallback, and it deleted the only signal that would
|
|
204
|
-
// tell AdapterProvider's toast (it branches on `readonly_when`) that a third
|
|
205
|
-
// reason had appeared. `Equal` is used rather than `extends` because
|
|
206
|
-
// `'readonly' | 'readonly_when' extends string` is true in the drifted
|
|
207
|
-
// direction too.
|
|
208
|
-
type _ReasonIsTheEnum = Assert<Equal<DroppedFieldsEvent['reason'], 'readonly' | 'readonly_when'>>;
|
|
209
|
-
type _ReasonIsNotString = Assert<Equal<Equal<DroppedFieldsEvent['reason'], string>, false>>;
|
|
210
|
-
|
|
211
|
-
expect(true).toBe(true);
|
|
212
|
-
});
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
describe('MetadataCacheStats is not the spec ICacheService stats', () => {
|
|
216
|
-
it('is pinned at compile time', () => {
|
|
217
|
-
type _SpecNotAny = Assert<Equal<IsAny<SpecCacheStats>, false>>;
|
|
218
|
-
|
|
219
|
-
// The two agree only on the two counters every cache has. Everything that
|
|
220
|
-
// says what KIND of cache each describes is exclusive to one side: the
|
|
221
|
-
// spec's is a server KV store sized in keys and bytes; this one is a bounded
|
|
222
|
-
// browser LRU that evicts, coalesces in-flight fetches and reports a rate.
|
|
223
|
-
type _Shared = Assert<Equal<Extract<keyof MetadataCacheStats, keyof SpecCacheStats>, 'hits' | 'misses'>>;
|
|
224
|
-
type _SpecOnly = Assert<Equal<Exclude<keyof SpecCacheStats, keyof MetadataCacheStats>, 'keyCount' | 'memoryUsage'>>;
|
|
225
|
-
type _LocalOnly = Assert<
|
|
226
|
-
Equal<
|
|
227
|
-
Exclude<keyof MetadataCacheStats, keyof SpecCacheStats>,
|
|
228
|
-
'size' | 'maxSize' | 'evictions' | 'coalesced' | 'hitRate'
|
|
229
|
-
>
|
|
230
|
-
>;
|
|
231
|
-
|
|
232
|
-
expect(true).toBe(true);
|
|
233
|
-
});
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
describe('MetadataClientSaveOptions is not the spec file-save options', () => {
|
|
237
|
-
it('is pinned at compile time', () => {
|
|
238
|
-
type _SpecNotAny = Assert<Equal<IsAny<SpecMetadataSaveOptions>, false>>;
|
|
239
|
-
|
|
240
|
-
// Disjoint: the spec's describes serialising an item to disk, this one
|
|
241
|
-
// describes a PUT to /api/v1/meta/*. Not one key is shared, so there was
|
|
242
|
-
// never a subset relationship to derive from.
|
|
243
|
-
type _NoOverlap = Assert<Equal<Extract<keyof MetadataClientSaveOptions, keyof SpecMetadataSaveOptions>, never>>;
|
|
244
|
-
|
|
245
|
-
// The keys that make this the HTTP envelope.
|
|
246
|
-
type _HasIfMatch = Assert<HasKey<MetadataClientSaveOptions, 'ifMatch'>>;
|
|
247
|
-
type _HasDraftMode = Assert<Equal<MetadataClientSaveOptions['mode'], 'draft' | 'publish' | undefined>>;
|
|
248
|
-
// …and the ones that make the spec's the file writer.
|
|
249
|
-
type _SpecHasPath = Assert<HasKey<SpecMetadataSaveOptions, 'path'>>;
|
|
250
|
-
type _SpecHasFormat = Assert<HasKey<SpecMetadataSaveOptions, 'format'>>;
|
|
251
|
-
|
|
252
|
-
expect(true).toBe(true);
|
|
253
|
-
});
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
describe('SecurityManagerPolicy is not the spec package-security policy', () => {
|
|
257
|
-
it('is pinned at compile time', () => {
|
|
258
|
-
type _SpecNotAny = Assert<Equal<IsAny<SpecSecurityPolicy>, false>>;
|
|
259
|
-
|
|
260
|
-
// Also fully disjoint. The spec's governs installing a plugin (scan
|
|
261
|
-
// schedule, licence allowlists, code signing, sandbox limits); this one
|
|
262
|
-
// governs what the browser adapter emits and hides (CSP header, audit log,
|
|
263
|
-
// field masking).
|
|
264
|
-
type _NoOverlap = Assert<Equal<Extract<keyof SecurityManagerPolicy, keyof SpecSecurityPolicy>, never>>;
|
|
265
|
-
type _SpecIsIdentified = Assert<Equal<SpecSecurityPolicy['id'], string>>;
|
|
266
|
-
type _LocalIsAllOptional = Assert<
|
|
267
|
-
Equal<
|
|
268
|
-
undefined extends SecurityManagerPolicy['csp' | 'auditLog' | 'dataMasking'] ? true : false,
|
|
269
|
-
true
|
|
270
|
-
>
|
|
271
|
-
>;
|
|
272
|
-
|
|
273
|
-
expect(true).toBe(true);
|
|
274
|
-
});
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
describe('DataApiValidationError is an Error, the spec ValidationError is a record', () => {
|
|
278
|
-
it('is pinned at compile time', () => {
|
|
279
|
-
type _SpecNotAny = Assert<Equal<IsAny<SpecValidationError>, false>>;
|
|
280
|
-
|
|
281
|
-
// The spec's is one entry in a validation report. `@object-ui/types`
|
|
282
|
-
// re-exports it under that name, so the two were importable side by side.
|
|
283
|
-
type _SpecShape = Assert<Equal<Exclude<keyof SpecValidationError, 'field' | 'message' | 'code'>, never>>;
|
|
284
|
-
|
|
285
|
-
// This one is a thrown runtime object: it has a `stack`, which no data
|
|
286
|
-
// record does, and it carries the whole list the spec type is one item of.
|
|
287
|
-
type _WeAreAnError = Assert<HasKey<DataApiValidationError, 'stack'>>;
|
|
288
|
-
type _WeCarryTheList = Assert<
|
|
289
|
-
Equal<DataApiValidationError['validationErrors'], Array<{ field: string; message: string }> | undefined>
|
|
290
|
-
>;
|
|
291
|
-
|
|
292
|
-
expect(true).toBe(true);
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
it('keeps `name === "ValidationError"` — the RUNTIME name is a wire contract', () => {
|
|
296
|
-
// Deliberately NOT renamed with the class. `normaliseClientError` in
|
|
297
|
-
// ./index.ts sniffs `e.name === 'ValidationError'` on errors thrown by
|
|
298
|
-
// `@objectstack/client`, and `@object-ui/react`'s `error-message` does the
|
|
299
|
-
// same on the way out. Renaming the TypeScript symbol is a source change;
|
|
300
|
-
// renaming this string would be a silent behaviour change.
|
|
301
|
-
expect(new DataApiValidationError('nope').name).toBe('ValidationError');
|
|
302
|
-
expect(new DataApiValidationError('nope')).toBeInstanceOf(Error);
|
|
303
|
-
expect(new DataApiValidationError('nope').code).toBe('VALIDATION_ERROR');
|
|
304
|
-
expect(new DataApiValidationError('nope').statusCode).toBe(400);
|
|
305
|
-
});
|
|
306
|
-
});
|
package/src/studio.ts
DELETED
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ObjectUI
|
|
3
|
-
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
-
*
|
|
5
|
-
* This source code is licensed under the MIT license found in the
|
|
6
|
-
* LICENSE file in the root directory of this source tree.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Studio module integration for @objectstack/spec v3.0.0
|
|
11
|
-
* Provides visual designer schema improvements: canvas, property editors, theme builder.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
export interface StudioCanvasConfig {
|
|
15
|
-
/** Canvas width */
|
|
16
|
-
width: number;
|
|
17
|
-
/** Canvas height */
|
|
18
|
-
height: number;
|
|
19
|
-
/** Background type */
|
|
20
|
-
background: 'grid' | 'dots' | 'lines' | 'none';
|
|
21
|
-
/** Grid size in pixels */
|
|
22
|
-
gridSize: number;
|
|
23
|
-
/** Snap to grid */
|
|
24
|
-
snapToGrid: boolean;
|
|
25
|
-
/** Zoom range */
|
|
26
|
-
zoom: {
|
|
27
|
-
min: number;
|
|
28
|
-
max: number;
|
|
29
|
-
step: number;
|
|
30
|
-
current: number;
|
|
31
|
-
};
|
|
32
|
-
/** Pan offset */
|
|
33
|
-
panOffset: { x: number; y: number };
|
|
34
|
-
/** Show minimap */
|
|
35
|
-
showMinimap: boolean;
|
|
36
|
-
/** Minimap position */
|
|
37
|
-
minimapPosition: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export interface StudioPropertyEditor {
|
|
41
|
-
/** Property name */
|
|
42
|
-
name: string;
|
|
43
|
-
/** Display label */
|
|
44
|
-
label: string;
|
|
45
|
-
/** Editor type */
|
|
46
|
-
type: 'text' | 'number' | 'boolean' | 'select' | 'color' | 'code' | 'expression';
|
|
47
|
-
/** Default value */
|
|
48
|
-
defaultValue?: unknown;
|
|
49
|
-
/** Options for select type */
|
|
50
|
-
options?: Array<{ label: string; value: string }>;
|
|
51
|
-
/** Group/category */
|
|
52
|
-
group?: string;
|
|
53
|
-
/** Description */
|
|
54
|
-
description?: string;
|
|
55
|
-
/** Whether the property supports live preview */
|
|
56
|
-
livePreview?: boolean;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export interface StudioThemeBuilderConfig {
|
|
60
|
-
/** Available color palettes */
|
|
61
|
-
palettes: StudioColorPalette[];
|
|
62
|
-
/** Typography presets */
|
|
63
|
-
typography: StudioTypographyPreset[];
|
|
64
|
-
/** Spacing scale */
|
|
65
|
-
spacing: number[];
|
|
66
|
-
/** Border radius options */
|
|
67
|
-
borderRadius: number[];
|
|
68
|
-
/** Shadow presets */
|
|
69
|
-
shadows: StudioShadowPreset[];
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export interface StudioColorPalette {
|
|
73
|
-
name: string;
|
|
74
|
-
colors: Record<string, string>;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export interface StudioTypographyPreset {
|
|
78
|
-
name: string;
|
|
79
|
-
fontFamily: string;
|
|
80
|
-
fontSize: Record<string, string>;
|
|
81
|
-
fontWeight: Record<string, number>;
|
|
82
|
-
lineHeight: Record<string, string>;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export interface StudioShadowPreset {
|
|
86
|
-
name: string;
|
|
87
|
-
value: string;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Default canvas configuration for designers.
|
|
92
|
-
*/
|
|
93
|
-
export function createDefaultCanvasConfig(overrides?: Partial<StudioCanvasConfig>): StudioCanvasConfig {
|
|
94
|
-
return {
|
|
95
|
-
width: 1200,
|
|
96
|
-
height: 800,
|
|
97
|
-
background: 'grid',
|
|
98
|
-
gridSize: 8,
|
|
99
|
-
snapToGrid: true,
|
|
100
|
-
zoom: {
|
|
101
|
-
min: 0.25,
|
|
102
|
-
max: 3,
|
|
103
|
-
step: 0.1,
|
|
104
|
-
current: 1,
|
|
105
|
-
},
|
|
106
|
-
panOffset: { x: 0, y: 0 },
|
|
107
|
-
showMinimap: false,
|
|
108
|
-
minimapPosition: 'bottom-right',
|
|
109
|
-
...overrides,
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Snap a position to the grid.
|
|
115
|
-
*/
|
|
116
|
-
export function snapToGrid(x: number, y: number, gridSize: number): { x: number; y: number } {
|
|
117
|
-
return {
|
|
118
|
-
x: Math.round(x / gridSize) * gridSize,
|
|
119
|
-
y: Math.round(y / gridSize) * gridSize,
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Calculate auto-layout positions for a set of items.
|
|
125
|
-
* Uses a simple grid-based layout algorithm.
|
|
126
|
-
*/
|
|
127
|
-
export function calculateAutoLayout(
|
|
128
|
-
items: Array<{ id: string; width: number; height: number }>,
|
|
129
|
-
canvasWidth: number,
|
|
130
|
-
padding: number = 40,
|
|
131
|
-
gap: number = 40,
|
|
132
|
-
): Array<{ id: string; x: number; y: number }> {
|
|
133
|
-
const positions: Array<{ id: string; x: number; y: number }> = [];
|
|
134
|
-
let currentX = padding;
|
|
135
|
-
let currentY = padding;
|
|
136
|
-
let rowMaxHeight = 0;
|
|
137
|
-
|
|
138
|
-
for (const item of items) {
|
|
139
|
-
// Wrap to next row if exceeds canvas width
|
|
140
|
-
if (currentX + item.width + padding > canvasWidth) {
|
|
141
|
-
currentX = padding;
|
|
142
|
-
currentY += rowMaxHeight + gap;
|
|
143
|
-
rowMaxHeight = 0;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
positions.push({ id: item.id, x: currentX, y: currentY });
|
|
147
|
-
currentX += item.width + gap;
|
|
148
|
-
rowMaxHeight = Math.max(rowMaxHeight, item.height);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
return positions;
|
|
152
|
-
}
|