@object-ui/data-objectstack 0.5.0 → 3.0.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.
@@ -0,0 +1,230 @@
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
+ export interface SecurityPolicy {
15
+ /** Content Security Policy configuration */
16
+ csp?: CSPConfig;
17
+ /** Audit logging configuration */
18
+ auditLog?: AuditLogConfig;
19
+ /** Data masking rules */
20
+ dataMasking?: DataMaskingConfig;
21
+ }
22
+
23
+ export interface CSPConfig {
24
+ /** Script sources */
25
+ scriptSrc: string[];
26
+ /** Style sources */
27
+ styleSrc: string[];
28
+ /** Image sources */
29
+ imgSrc: string[];
30
+ /** Connect sources (APIs, WebSockets) */
31
+ connectSrc: string[];
32
+ /** Font sources */
33
+ fontSrc: string[];
34
+ /** Frame sources */
35
+ frameSrc?: string[];
36
+ /** Report URI for violations */
37
+ reportUri?: string;
38
+ }
39
+
40
+ export interface AuditLogConfig {
41
+ /** Whether audit logging is enabled */
42
+ enabled: boolean;
43
+ /** Events to audit */
44
+ events: AuditEventType[];
45
+ /** Log retention days */
46
+ retentionDays?: number;
47
+ /** External log destination */
48
+ destination?: 'console' | 'server' | 'both';
49
+ }
50
+
51
+ export type AuditEventType =
52
+ | 'auth.login'
53
+ | 'auth.logout'
54
+ | 'auth.failed'
55
+ | 'data.create'
56
+ | 'data.read'
57
+ | 'data.update'
58
+ | 'data.delete'
59
+ | 'admin.config'
60
+ | 'admin.user'
61
+ | 'admin.role';
62
+
63
+ export interface DataMaskingConfig {
64
+ /** Fields to mask */
65
+ rules: DataMaskingRule[];
66
+ /** Default masking character */
67
+ maskChar?: string;
68
+ }
69
+
70
+ export interface DataMaskingRule {
71
+ /** Field name or pattern */
72
+ field: string;
73
+ /** Masking strategy */
74
+ strategy: 'full' | 'partial' | 'hash' | 'redact';
75
+ /** Number of visible characters (for partial masking) */
76
+ visibleChars?: number;
77
+ /** Roles that can see unmasked data */
78
+ exemptRoles?: string[];
79
+ }
80
+
81
+ export interface AuditLogEntry {
82
+ /** Event timestamp */
83
+ timestamp: string;
84
+ /** Event type */
85
+ event: AuditEventType;
86
+ /** User who triggered the event */
87
+ userId: string;
88
+ /** Target resource */
89
+ resource?: string;
90
+ /** Record ID */
91
+ recordId?: string;
92
+ /** Additional details */
93
+ details?: Record<string, unknown>;
94
+ /** IP address */
95
+ ipAddress?: string;
96
+ }
97
+
98
+ /**
99
+ * Security policy manager.
100
+ * Handles CSP generation, audit logging, and data masking.
101
+ */
102
+ export class SecurityManager {
103
+ private policy: SecurityPolicy;
104
+ private auditLog: AuditLogEntry[] = [];
105
+
106
+ constructor(policy: SecurityPolicy = {}) {
107
+ this.policy = policy;
108
+ }
109
+
110
+ /**
111
+ * Generate a CSP header string from the configuration.
112
+ */
113
+ generateCSPHeader(): string {
114
+ const csp = this.policy.csp;
115
+ if (!csp) return '';
116
+
117
+ const directives: string[] = [];
118
+
119
+ if (csp.scriptSrc?.length) directives.push(`script-src ${csp.scriptSrc.join(' ')}`);
120
+ if (csp.styleSrc?.length) directives.push(`style-src ${csp.styleSrc.join(' ')}`);
121
+ if (csp.imgSrc?.length) directives.push(`img-src ${csp.imgSrc.join(' ')}`);
122
+ if (csp.connectSrc?.length) directives.push(`connect-src ${csp.connectSrc.join(' ')}`);
123
+ if (csp.fontSrc?.length) directives.push(`font-src ${csp.fontSrc.join(' ')}`);
124
+ if (csp.frameSrc?.length) directives.push(`frame-src ${csp.frameSrc.join(' ')}`);
125
+ if (csp.reportUri) directives.push(`report-uri ${csp.reportUri}`);
126
+
127
+ return directives.join('; ');
128
+ }
129
+
130
+ /**
131
+ * Record an audit log entry.
132
+ */
133
+ recordAudit(entry: Omit<AuditLogEntry, 'timestamp'>): void {
134
+ if (!this.policy.auditLog?.enabled) return;
135
+ if (this.policy.auditLog.events && !this.policy.auditLog.events.includes(entry.event)) return;
136
+
137
+ const fullEntry: AuditLogEntry = {
138
+ ...entry,
139
+ timestamp: new Date().toISOString(),
140
+ };
141
+
142
+ this.auditLog.push(fullEntry);
143
+
144
+ const dest = this.policy.auditLog.destination ?? 'console';
145
+ if (dest === 'console' || dest === 'both') {
146
+ console.info('[AUDIT]', fullEntry.event, fullEntry.userId, fullEntry.resource ?? '');
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Get audit log entries.
152
+ */
153
+ getAuditLog(filter?: { event?: AuditEventType; userId?: string; since?: string }): AuditLogEntry[] {
154
+ let entries = [...this.auditLog];
155
+ if (filter?.event) entries = entries.filter(e => e.event === filter.event);
156
+ if (filter?.userId) entries = entries.filter(e => e.userId === filter.userId);
157
+ if (filter?.since) entries = entries.filter(e => e.timestamp >= filter.since!);
158
+ return entries;
159
+ }
160
+
161
+ /**
162
+ * Apply data masking to a record.
163
+ */
164
+ maskRecord(record: Record<string, unknown>, userRoles: string[] = []): Record<string, unknown> {
165
+ if (!this.policy.dataMasking?.rules?.length) return record;
166
+
167
+ const masked = { ...record };
168
+ const maskChar = this.policy.dataMasking.maskChar ?? '*';
169
+
170
+ for (const rule of this.policy.dataMasking.rules) {
171
+ if (!(rule.field in masked) || masked[rule.field] == null) continue;
172
+
173
+ // Check role exemptions
174
+ if (rule.exemptRoles?.some(role => userRoles.includes(role))) continue;
175
+
176
+ const value = String(masked[rule.field]);
177
+
178
+ switch (rule.strategy) {
179
+ case 'full':
180
+ masked[rule.field] = maskChar.repeat(value.length);
181
+ break;
182
+ case 'partial': {
183
+ const visible = rule.visibleChars ?? 4;
184
+ // Values shorter than visibleChars are fully masked for security
185
+ if (value.length <= visible) {
186
+ masked[rule.field] = maskChar.repeat(value.length);
187
+ } else {
188
+ masked[rule.field] = value.slice(0, visible) + maskChar.repeat(value.length - visible);
189
+ }
190
+ break;
191
+ }
192
+ case 'hash':
193
+ masked[rule.field] = `[HASHED:${simpleHash(value)}]`;
194
+ break;
195
+ case 'redact':
196
+ masked[rule.field] = '[REDACTED]';
197
+ break;
198
+ }
199
+ }
200
+
201
+ return masked;
202
+ }
203
+
204
+ /**
205
+ * Update the security policy.
206
+ */
207
+ updatePolicy(policy: Partial<SecurityPolicy>): void {
208
+ this.policy = { ...this.policy, ...policy };
209
+ }
210
+
211
+ /**
212
+ * Get current security policy.
213
+ */
214
+ getPolicy(): SecurityPolicy {
215
+ return { ...this.policy };
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Simple hash function for data masking (not cryptographic).
221
+ */
222
+ function simpleHash(str: string): string {
223
+ let hash = 0;
224
+ for (let i = 0; i < str.length; i++) {
225
+ const char = str.charCodeAt(i);
226
+ hash = ((hash << 5) - hash) + char;
227
+ hash = hash & hash; // Convert to 32-bit integer
228
+ }
229
+ return Math.abs(hash).toString(36);
230
+ }
package/src/studio.ts ADDED
@@ -0,0 +1,152 @@
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
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Tests for ObjectStackAdapter file upload integration
3
+ */
4
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
5
+ import { ObjectStackAdapter } from './index';
6
+
7
+ describe('ObjectStackAdapter File Upload', () => {
8
+ let adapter: ObjectStackAdapter;
9
+
10
+ beforeEach(() => {
11
+ adapter = new ObjectStackAdapter({
12
+ baseUrl: 'http://localhost:3000',
13
+ autoReconnect: false,
14
+ });
15
+ vi.clearAllMocks();
16
+ });
17
+
18
+ describe('uploadFile', () => {
19
+ it('should be a method on the adapter', () => {
20
+ expect(typeof adapter.uploadFile).toBe('function');
21
+ });
22
+
23
+ it('should call fetch with multipart form data when connected', async () => {
24
+ const mockResponse = {
25
+ ok: true,
26
+ json: vi.fn().mockResolvedValue({
27
+ id: 'file-1',
28
+ filename: 'test.pdf',
29
+ mimeType: 'application/pdf',
30
+ size: 1024,
31
+ url: 'http://localhost:3000/files/file-1',
32
+ }),
33
+ };
34
+
35
+ global.fetch = vi.fn().mockResolvedValue(mockResponse);
36
+
37
+ // Manually set connected state by accessing private field
38
+ (adapter as any).connected = true;
39
+ (adapter as any).connectionState = 'connected';
40
+
41
+ const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' });
42
+
43
+ const result = await adapter.uploadFile('documents', file, {
44
+ recordId: 'rec-123',
45
+ fieldName: 'attachment',
46
+ });
47
+
48
+ expect(global.fetch).toHaveBeenCalledWith(
49
+ expect.stringContaining('/api/data/documents/upload'),
50
+ expect.objectContaining({
51
+ method: 'POST',
52
+ body: expect.any(FormData),
53
+ }),
54
+ );
55
+
56
+ expect(result.id).toBe('file-1');
57
+ expect(result.filename).toBe('test.pdf');
58
+ });
59
+
60
+ it('should throw on upload failure', async () => {
61
+ const mockResponse = {
62
+ ok: false,
63
+ status: 413,
64
+ statusText: 'Payload Too Large',
65
+ json: vi.fn().mockResolvedValue({ message: 'File too large' }),
66
+ };
67
+
68
+ global.fetch = vi.fn().mockResolvedValue(mockResponse);
69
+
70
+ // Manually set connected state
71
+ (adapter as any).connected = true;
72
+ (adapter as any).connectionState = 'connected';
73
+
74
+ const file = new File(['test'], 'large.bin', { type: 'application/octet-stream' });
75
+
76
+ await expect(adapter.uploadFile('documents', file)).rejects.toThrow('File too large');
77
+ });
78
+ });
79
+
80
+ describe('uploadFiles', () => {
81
+ it('should be a method on the adapter', () => {
82
+ expect(typeof adapter.uploadFiles).toBe('function');
83
+ });
84
+
85
+ it('should upload multiple files', async () => {
86
+ const mockResponse = {
87
+ ok: true,
88
+ json: vi.fn().mockResolvedValue([
89
+ { id: 'file-1', filename: 'a.pdf', mimeType: 'application/pdf', size: 100, url: '/files/1' },
90
+ { id: 'file-2', filename: 'b.pdf', mimeType: 'application/pdf', size: 200, url: '/files/2' },
91
+ ]),
92
+ };
93
+
94
+ global.fetch = vi.fn().mockResolvedValue(mockResponse);
95
+
96
+ // Manually set connected state
97
+ (adapter as any).connected = true;
98
+ (adapter as any).connectionState = 'connected';
99
+
100
+ const files = [
101
+ new File(['content1'], 'a.pdf', { type: 'application/pdf' }),
102
+ new File(['content2'], 'b.pdf', { type: 'application/pdf' }),
103
+ ];
104
+
105
+ const results = await adapter.uploadFiles('documents', files);
106
+
107
+ expect(results).toHaveLength(2);
108
+ expect(results[0].id).toBe('file-1');
109
+ expect(results[1].id).toBe('file-2');
110
+ });
111
+ });
112
+ });