@burgantech/context-store 0.1.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,101 @@
1
+ import { Boundary, Storage, Envelope, ContextStoreOptions, Observable, IContextStore } from './types';
2
+ export declare class ContextStore implements IContextStore {
3
+ private _activeDevice;
4
+ private _activeUser;
5
+ private _activeSubject;
6
+ private memoryStore;
7
+ private encryptionKey;
8
+ private timeManager;
9
+ private log;
10
+ private appName;
11
+ private appVersion;
12
+ private listeners;
13
+ private subjectsByStorageKey;
14
+ private constructor();
15
+ static create(options: ContextStoreOptions): ContextStore;
16
+ get activeDevice(): string | null;
17
+ get activeUser(): string | null;
18
+ set activeUser(value: string | null);
19
+ get activeSubject(): string | null;
20
+ set activeSubject(value: string | null);
21
+ getServerTime(): Promise<Date>;
22
+ setEncryptionKey(key: string): void;
23
+ get isEncryptionKeySet(): boolean;
24
+ revokeEncryptionKey(): void;
25
+ setData(boundary: Boundary, key: string, value: any, options?: {
26
+ storage?: Storage;
27
+ ttl?: number;
28
+ dataPath?: string;
29
+ }): void;
30
+ getData<T = any>(boundary: Boundary, key: string, options?: {
31
+ storage?: Storage;
32
+ dataPath?: string;
33
+ }): T | undefined;
34
+ getDataMetadata(boundary: Boundary, key: string, options?: {
35
+ storage?: Storage;
36
+ }): Envelope | undefined;
37
+ deleteData(boundary: Boundary, key: string, options?: {
38
+ storage?: Storage;
39
+ dataPath?: string;
40
+ }): void;
41
+ batchSet(operations: Array<{
42
+ boundary: Boundary;
43
+ key: string;
44
+ value: any;
45
+ storage?: Storage;
46
+ ttl?: number;
47
+ }>): void;
48
+ batchGet(operations: Array<{
49
+ boundary: Boundary;
50
+ key: string;
51
+ storage?: Storage;
52
+ }>): Array<{
53
+ boundary: Boundary;
54
+ key: string;
55
+ value: any;
56
+ }>;
57
+ observeData(boundary: Boundary, key: string, options?: {
58
+ storage?: Storage;
59
+ dataPath?: string;
60
+ }): Observable<any>;
61
+ addListener(listenerId: string, boundary: Boundary, key: string, callback: (value: any) => void, options?: {
62
+ storage?: Storage;
63
+ dataPath?: string;
64
+ }): void;
65
+ removeListener(listenerId: string): void;
66
+ clearAllListeners(): void;
67
+ findKeys(boundary: Boundary, partialKey: string, options?: {
68
+ storage?: Storage;
69
+ }): string[];
70
+ clearData(boundary: Boundary, options?: {
71
+ storage?: Storage;
72
+ partialKey?: string;
73
+ }): void;
74
+ exportData(boundary: Boundary, options?: {
75
+ storage?: Storage;
76
+ partialKey?: string;
77
+ }): Record<string, any>;
78
+ importData(boundary: Boundary, data: Record<string, any>, options?: {
79
+ storage?: Storage;
80
+ overwrite?: boolean;
81
+ }): void;
82
+ cleanup(options?: {
83
+ boundary?: Boundary;
84
+ storage?: Storage;
85
+ }): void;
86
+ private buildStorageKey;
87
+ private buildKeyPrefix;
88
+ private identityScope;
89
+ private readRaw;
90
+ private writeRaw;
91
+ private deleteRaw;
92
+ private allKeysIn;
93
+ private readEnvelope;
94
+ private makeEnvelope;
95
+ private touchEnvelope;
96
+ private emit;
97
+ private isPemOrDer;
98
+ private initDeviceId;
99
+ private detectAppName;
100
+ private detectAppVersion;
101
+ }
@@ -0,0 +1,555 @@
1
+ import { Boundary, Storage, } from './types';
2
+ import { ServerTimeManager } from './server-time';
3
+ import { encrypt, decrypt } from './crypto';
4
+ const SDK_VERSION = '0.1.0';
5
+ const KEY_PREFIX = 'cs';
6
+ // ---------------------------------------------------------------------------
7
+ // Internal Subject (minimal pub-sub)
8
+ // ---------------------------------------------------------------------------
9
+ class Subject {
10
+ subs = new Set();
11
+ next(v) {
12
+ for (const cb of this.subs) {
13
+ try {
14
+ cb(v);
15
+ }
16
+ catch {
17
+ /* subscriber errors must not propagate */
18
+ }
19
+ }
20
+ }
21
+ subscribe(cb) {
22
+ this.subs.add(cb);
23
+ return { unsubscribe: () => this.subs.delete(cb) };
24
+ }
25
+ clear() {
26
+ this.subs.clear();
27
+ }
28
+ }
29
+ // ---------------------------------------------------------------------------
30
+ // ContextStore
31
+ // ---------------------------------------------------------------------------
32
+ export class ContextStore {
33
+ // Identity
34
+ _activeDevice = null;
35
+ _activeUser = null;
36
+ _activeSubject = null;
37
+ // Storage
38
+ memoryStore = new Map();
39
+ encryptionKey = null;
40
+ // Time
41
+ timeManager;
42
+ // Metadata
43
+ log;
44
+ appName;
45
+ appVersion;
46
+ // Reactivity
47
+ listeners = new Map();
48
+ subjectsByStorageKey = new Map();
49
+ // ---------------------------------------------------------------------------
50
+ // Construction
51
+ // ---------------------------------------------------------------------------
52
+ constructor(options) {
53
+ this.timeManager = new ServerTimeManager(options);
54
+ this.log = options.onLog;
55
+ this.appName = options.appName ?? this.detectAppName();
56
+ this.appVersion = options.appVersion ?? this.detectAppVersion();
57
+ this._activeDevice = this.initDeviceId();
58
+ }
59
+ static create(options) {
60
+ return new ContextStore(options);
61
+ }
62
+ // ---------------------------------------------------------------------------
63
+ // Properties
64
+ // ---------------------------------------------------------------------------
65
+ get activeDevice() {
66
+ return this._activeDevice;
67
+ }
68
+ get activeUser() {
69
+ return this._activeUser;
70
+ }
71
+ set activeUser(value) {
72
+ this._activeUser = value;
73
+ }
74
+ get activeSubject() {
75
+ return this._activeSubject;
76
+ }
77
+ set activeSubject(value) {
78
+ this._activeSubject = value;
79
+ }
80
+ async getServerTime() {
81
+ return this.timeManager.getServerTime();
82
+ }
83
+ // ---------------------------------------------------------------------------
84
+ // Encryption Key
85
+ // ---------------------------------------------------------------------------
86
+ setEncryptionKey(key) {
87
+ this.encryptionKey = key;
88
+ }
89
+ get isEncryptionKeySet() {
90
+ return this.encryptionKey !== null;
91
+ }
92
+ revokeEncryptionKey() {
93
+ this.encryptionKey = null;
94
+ }
95
+ // ---------------------------------------------------------------------------
96
+ // CRUD
97
+ // ---------------------------------------------------------------------------
98
+ setData(boundary, key, value, options) {
99
+ const storage = options?.storage ?? Storage.secureStorage;
100
+ const storageKey = this.buildStorageKey(storage, boundary, key);
101
+ if (!storageKey) {
102
+ this.log?.('warn', 'Cannot resolve storage key — identity not set', undefined, { boundary, key });
103
+ return;
104
+ }
105
+ // PEM/DER bypass
106
+ if (this.isPemOrDer(value)) {
107
+ this.writeRaw(storage, storageKey, typeof value === 'string' ? value : JSON.stringify(value));
108
+ this.emit(storageKey, value);
109
+ return;
110
+ }
111
+ const now = this.timeManager.getNow();
112
+ const existing = this.readEnvelope(storage, storageKey);
113
+ let targetValue = value;
114
+ if (options?.dataPath && existing) {
115
+ const obj = structuredClone(existing.data);
116
+ setNested(obj, options.dataPath, value);
117
+ targetValue = obj;
118
+ }
119
+ const envelope = existing
120
+ ? this.touchEnvelope(existing, targetValue, now, options?.ttl)
121
+ : this.makeEnvelope(targetValue, now, options?.ttl);
122
+ this.writeRaw(storage, storageKey, JSON.stringify(envelope));
123
+ this.emit(storageKey, envelope.data);
124
+ }
125
+ getData(boundary, key, options) {
126
+ const storage = options?.storage ?? Storage.secureStorage;
127
+ const storageKey = this.buildStorageKey(storage, boundary, key);
128
+ if (!storageKey)
129
+ return undefined;
130
+ const raw = this.readRaw(storage, storageKey);
131
+ if (raw === null)
132
+ return undefined;
133
+ if (raw.trimStart().startsWith('-----BEGIN')) {
134
+ return raw;
135
+ }
136
+ let envelope;
137
+ try {
138
+ envelope = JSON.parse(raw);
139
+ }
140
+ catch {
141
+ return undefined;
142
+ }
143
+ if (!envelope.createdAt)
144
+ return raw;
145
+ const now = this.timeManager.getNow();
146
+ if (envelope.expiry && now >= new Date(envelope.expiry)) {
147
+ this.deleteRaw(storage, storageKey);
148
+ this.log?.('debug', 'Lazy cleanup: expired entry removed', undefined, { boundary, key });
149
+ return undefined;
150
+ }
151
+ const data = envelope.data;
152
+ return options?.dataPath ? getNested(data, options.dataPath) : data;
153
+ }
154
+ getDataMetadata(boundary, key, options) {
155
+ const storage = options?.storage ?? Storage.secureStorage;
156
+ const storageKey = this.buildStorageKey(storage, boundary, key);
157
+ if (!storageKey)
158
+ return undefined;
159
+ return this.readEnvelope(storage, storageKey) ?? undefined;
160
+ }
161
+ deleteData(boundary, key, options) {
162
+ const storage = options?.storage ?? Storage.secureStorage;
163
+ const storageKey = this.buildStorageKey(storage, boundary, key);
164
+ if (!storageKey)
165
+ return;
166
+ if (options?.dataPath) {
167
+ const envelope = this.readEnvelope(storage, storageKey);
168
+ if (envelope) {
169
+ const obj = structuredClone(envelope.data);
170
+ deleteNested(obj, options.dataPath);
171
+ const updated = this.touchEnvelope(envelope, obj, this.timeManager.getNow());
172
+ this.writeRaw(storage, storageKey, JSON.stringify(updated));
173
+ this.emit(storageKey, obj);
174
+ }
175
+ return;
176
+ }
177
+ this.deleteRaw(storage, storageKey);
178
+ this.emit(storageKey, undefined);
179
+ }
180
+ // ---------------------------------------------------------------------------
181
+ // Batch
182
+ // ---------------------------------------------------------------------------
183
+ batchSet(operations) {
184
+ for (const op of operations) {
185
+ this.setData(op.boundary, op.key, op.value, { storage: op.storage, ttl: op.ttl });
186
+ }
187
+ }
188
+ batchGet(operations) {
189
+ return operations.map((op) => ({
190
+ boundary: op.boundary,
191
+ key: op.key,
192
+ value: this.getData(op.boundary, op.key, { storage: op.storage }),
193
+ }));
194
+ }
195
+ // ---------------------------------------------------------------------------
196
+ // Observe / Listen
197
+ // ---------------------------------------------------------------------------
198
+ observeData(boundary, key, options) {
199
+ const storage = options?.storage ?? Storage.secureStorage;
200
+ const storageKey = this.buildStorageKey(storage, boundary, key);
201
+ const subject = new Subject();
202
+ if (storageKey) {
203
+ const list = this.subjectsByStorageKey.get(storageKey) ?? [];
204
+ list.push(subject);
205
+ this.subjectsByStorageKey.set(storageKey, list);
206
+ }
207
+ return {
208
+ subscribe: (callback) => {
209
+ const inner = subject.subscribe((value) => {
210
+ callback(options?.dataPath ? getNested(value, options.dataPath) : value);
211
+ });
212
+ return {
213
+ unsubscribe: () => {
214
+ inner.unsubscribe();
215
+ if (storageKey) {
216
+ const arr = this.subjectsByStorageKey.get(storageKey);
217
+ if (arr) {
218
+ const idx = arr.indexOf(subject);
219
+ if (idx !== -1)
220
+ arr.splice(idx, 1);
221
+ if (arr.length === 0)
222
+ this.subjectsByStorageKey.delete(storageKey);
223
+ }
224
+ }
225
+ },
226
+ };
227
+ },
228
+ };
229
+ }
230
+ addListener(listenerId, boundary, key, callback, options) {
231
+ const storage = options?.storage ?? Storage.secureStorage;
232
+ this.listeners.set(listenerId, { id: listenerId, boundary, key, storage, dataPath: options?.dataPath, callback });
233
+ }
234
+ removeListener(listenerId) {
235
+ this.listeners.delete(listenerId);
236
+ }
237
+ clearAllListeners() {
238
+ this.listeners.clear();
239
+ for (const list of this.subjectsByStorageKey.values()) {
240
+ for (const s of list)
241
+ s.clear();
242
+ }
243
+ this.subjectsByStorageKey.clear();
244
+ }
245
+ // ---------------------------------------------------------------------------
246
+ // Query
247
+ // ---------------------------------------------------------------------------
248
+ findKeys(boundary, partialKey, options) {
249
+ const storage = options?.storage ?? Storage.secureStorage;
250
+ const prefix = this.buildKeyPrefix(storage, boundary);
251
+ if (!prefix)
252
+ return [];
253
+ const needle = partialKey ? `${prefix}:${partialKey}` : `${prefix}:`;
254
+ return this.allKeysIn(storage)
255
+ .filter((k) => k.startsWith(needle))
256
+ .map((k) => k.slice(prefix.length + 1));
257
+ }
258
+ clearData(boundary, options) {
259
+ const storage = options?.storage ?? Storage.secureStorage;
260
+ const prefix = this.buildKeyPrefix(storage, boundary);
261
+ if (!prefix)
262
+ return;
263
+ const needle = options?.partialKey ? `${prefix}:${options.partialKey}` : `${prefix}:`;
264
+ for (const k of this.allKeysIn(storage)) {
265
+ if (k.startsWith(needle))
266
+ this.deleteRaw(storage, k);
267
+ }
268
+ }
269
+ // ---------------------------------------------------------------------------
270
+ // Export / Import
271
+ // ---------------------------------------------------------------------------
272
+ exportData(boundary, options) {
273
+ const storage = options?.storage ?? Storage.secureStorage;
274
+ const prefix = this.buildKeyPrefix(storage, boundary);
275
+ if (!prefix)
276
+ return {};
277
+ const needle = options?.partialKey ? `${prefix}:${options.partialKey}` : `${prefix}:`;
278
+ const result = {};
279
+ for (const k of this.allKeysIn(storage)) {
280
+ if (!k.startsWith(needle))
281
+ continue;
282
+ const raw = this.readRaw(storage, k);
283
+ if (raw === null)
284
+ continue;
285
+ const userKey = k.slice(prefix.length + 1);
286
+ try {
287
+ result[userKey] = JSON.parse(raw);
288
+ }
289
+ catch {
290
+ result[userKey] = raw;
291
+ }
292
+ }
293
+ return result;
294
+ }
295
+ importData(boundary, data, options) {
296
+ const storage = options?.storage ?? Storage.secureStorage;
297
+ const overwrite = options?.overwrite ?? false;
298
+ for (const [key, value] of Object.entries(data)) {
299
+ const storageKey = this.buildStorageKey(storage, boundary, key);
300
+ if (!storageKey)
301
+ continue;
302
+ if (!overwrite && this.readRaw(storage, storageKey) !== null)
303
+ continue;
304
+ this.writeRaw(storage, storageKey, JSON.stringify(value));
305
+ }
306
+ }
307
+ // ---------------------------------------------------------------------------
308
+ // Cleanup
309
+ // ---------------------------------------------------------------------------
310
+ cleanup(options) {
311
+ const boundaries = options?.boundary ? [options.boundary] : [Boundary.device, Boundary.user, Boundary.subject];
312
+ const storages = options?.storage
313
+ ? [options.storage]
314
+ : [Storage.secureStorage, Storage.secureStorageEncrypted, Storage.memory, Storage.localStorage];
315
+ const now = this.timeManager.getNow();
316
+ let cleaned = 0;
317
+ for (const storage of storages) {
318
+ for (const boundary of boundaries) {
319
+ const prefix = this.buildKeyPrefix(storage, boundary);
320
+ if (!prefix)
321
+ continue;
322
+ for (const k of this.allKeysIn(storage)) {
323
+ if (!k.startsWith(`${prefix}:`))
324
+ continue;
325
+ const raw = this.readRaw(storage, k);
326
+ if (raw === null)
327
+ continue;
328
+ try {
329
+ const env = JSON.parse(raw);
330
+ if (env.expiry && now >= new Date(env.expiry)) {
331
+ this.deleteRaw(storage, k);
332
+ cleaned++;
333
+ }
334
+ }
335
+ catch {
336
+ /* not an envelope — skip */
337
+ }
338
+ }
339
+ }
340
+ }
341
+ this.log?.('info', `Cleanup completed: ${cleaned} expired entries removed`);
342
+ }
343
+ // ===========================================================================
344
+ // Private — storage plumbing
345
+ // ===========================================================================
346
+ buildStorageKey(storage, boundary, key) {
347
+ const prefix = this.buildKeyPrefix(storage, boundary);
348
+ return prefix ? `${prefix}:${key}` : null;
349
+ }
350
+ buildKeyPrefix(storage, boundary) {
351
+ const identity = this.identityScope(boundary);
352
+ return identity ? `${KEY_PREFIX}:${storage}:${boundary}:${identity}` : null;
353
+ }
354
+ identityScope(boundary) {
355
+ const d = this._activeDevice;
356
+ if (!d)
357
+ return null;
358
+ switch (boundary) {
359
+ case Boundary.device:
360
+ return d;
361
+ case Boundary.user:
362
+ return this._activeUser ? `${d}:${this._activeUser}` : null;
363
+ case Boundary.subject:
364
+ return this._activeUser && this._activeSubject
365
+ ? `${d}:${this._activeUser}:${this._activeSubject}`
366
+ : null;
367
+ }
368
+ }
369
+ readRaw(storage, key) {
370
+ this.log?.('debug', `readRaw [${storage}] ${key}`);
371
+ if (storage === Storage.memory)
372
+ return this.memoryStore.get(key) ?? null;
373
+ if (storage === Storage.secureStorageEncrypted) {
374
+ if (!this.encryptionKey) {
375
+ this.log?.('warn', 'Encryption key not set — cannot read encrypted storage');
376
+ return null;
377
+ }
378
+ const cipher = globalThis.localStorage.getItem(key);
379
+ if (!cipher)
380
+ return null;
381
+ try {
382
+ const plain = decrypt(cipher, this.encryptionKey);
383
+ this.log?.('debug', 'Encrypted read OK');
384
+ return plain;
385
+ }
386
+ catch (err) {
387
+ this.log?.('error', 'Decryption failed — wrong encryption key', err);
388
+ return null;
389
+ }
390
+ }
391
+ return globalThis.localStorage.getItem(key);
392
+ }
393
+ writeRaw(storage, key, value) {
394
+ this.log?.('debug', `writeRaw [${storage}] ${key}`);
395
+ if (storage === Storage.memory) {
396
+ this.memoryStore.set(key, value);
397
+ return;
398
+ }
399
+ if (storage === Storage.secureStorageEncrypted) {
400
+ if (!this.encryptionKey) {
401
+ this.log?.('warn', 'Encryption key not set — cannot write to encrypted storage');
402
+ return;
403
+ }
404
+ try {
405
+ globalThis.localStorage.setItem(key, encrypt(value, this.encryptionKey));
406
+ }
407
+ catch (err) {
408
+ this.log?.('error', 'Encryption / storage write failed', err);
409
+ }
410
+ return;
411
+ }
412
+ try {
413
+ globalThis.localStorage.setItem(key, value);
414
+ }
415
+ catch (err) {
416
+ this.log?.('error', 'Storage write failed', err);
417
+ }
418
+ }
419
+ deleteRaw(storage, key) {
420
+ if (storage === Storage.memory) {
421
+ this.memoryStore.delete(key);
422
+ return;
423
+ }
424
+ globalThis.localStorage.removeItem(key);
425
+ }
426
+ allKeysIn(storage) {
427
+ if (storage === Storage.memory)
428
+ return [...this.memoryStore.keys()];
429
+ const keys = [];
430
+ for (let i = 0; i < globalThis.localStorage.length; i++) {
431
+ const k = globalThis.localStorage.key(i);
432
+ if (k?.startsWith(KEY_PREFIX))
433
+ keys.push(k);
434
+ }
435
+ return keys;
436
+ }
437
+ // ===========================================================================
438
+ // Private — envelope helpers
439
+ // ===========================================================================
440
+ readEnvelope(storage, storageKey) {
441
+ const raw = this.readRaw(storage, storageKey);
442
+ if (!raw)
443
+ return null;
444
+ try {
445
+ const obj = JSON.parse(raw);
446
+ return obj.createdAt ? obj : null;
447
+ }
448
+ catch {
449
+ return null;
450
+ }
451
+ }
452
+ makeEnvelope(data, now, ttl) {
453
+ const iso = now.toISOString();
454
+ return {
455
+ data,
456
+ expiry: ttl ? new Date(now.getTime() + ttl).toISOString() : null,
457
+ createdAt: iso,
458
+ updatedAt: iso,
459
+ appName: this.appName,
460
+ appVersion: this.appVersion,
461
+ sdkVersion: SDK_VERSION,
462
+ };
463
+ }
464
+ touchEnvelope(existing, data, now, ttl) {
465
+ return {
466
+ ...existing,
467
+ data,
468
+ updatedAt: now.toISOString(),
469
+ expiry: ttl ? new Date(now.getTime() + ttl).toISOString() : existing.expiry,
470
+ appName: this.appName,
471
+ appVersion: this.appVersion,
472
+ sdkVersion: SDK_VERSION,
473
+ };
474
+ }
475
+ // ===========================================================================
476
+ // Private — reactivity
477
+ // ===========================================================================
478
+ emit(storageKey, value) {
479
+ for (const l of this.listeners.values()) {
480
+ const lk = this.buildStorageKey(l.storage, l.boundary, l.key);
481
+ if (lk !== storageKey)
482
+ continue;
483
+ const v = l.dataPath ? getNested(value, l.dataPath) : value;
484
+ try {
485
+ l.callback(v);
486
+ }
487
+ catch {
488
+ /* listener errors must not propagate */
489
+ }
490
+ }
491
+ const subs = this.subjectsByStorageKey.get(storageKey);
492
+ if (subs)
493
+ for (const s of subs)
494
+ s.next(value);
495
+ }
496
+ // ===========================================================================
497
+ // Private — platform detection
498
+ // ===========================================================================
499
+ isPemOrDer(value) {
500
+ if (typeof value === 'string')
501
+ return value.trimStart().startsWith('-----BEGIN');
502
+ if (value instanceof Uint8Array || value instanceof ArrayBuffer)
503
+ return true;
504
+ return false;
505
+ }
506
+ initDeviceId() {
507
+ const SESSION_KEY = 'cs:deviceId';
508
+ let id = globalThis.sessionStorage?.getItem(SESSION_KEY);
509
+ if (!id) {
510
+ id = globalThis.crypto.randomUUID();
511
+ globalThis.sessionStorage?.setItem(SESSION_KEY, id);
512
+ }
513
+ return id;
514
+ }
515
+ detectAppName() {
516
+ try {
517
+ return globalThis.document?.title || 'unknown';
518
+ }
519
+ catch {
520
+ return 'unknown';
521
+ }
522
+ }
523
+ detectAppVersion() {
524
+ try {
525
+ const meta = globalThis.document?.querySelector('meta[name="version"]');
526
+ return meta?.getAttribute('content') ?? '0.0.0';
527
+ }
528
+ catch {
529
+ return '0.0.0';
530
+ }
531
+ }
532
+ }
533
+ // ===========================================================================
534
+ // Utility — nested property access
535
+ // ===========================================================================
536
+ function getNested(obj, path) {
537
+ return path.split('.').reduce((o, k) => o?.[k], obj);
538
+ }
539
+ function setNested(obj, path, value) {
540
+ const parts = path.split('.');
541
+ const last = parts.pop();
542
+ const target = parts.reduce((o, k) => {
543
+ if (o[k] == null)
544
+ o[k] = {};
545
+ return o[k];
546
+ }, obj);
547
+ target[last] = value;
548
+ }
549
+ function deleteNested(obj, path) {
550
+ const parts = path.split('.');
551
+ const last = parts.pop();
552
+ const target = parts.reduce((o, k) => o?.[k], obj);
553
+ if (target)
554
+ delete target[last];
555
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Sync encryption for web runtime.
3
+ * Production implementations MUST use AES-256-GCM via platform crypto APIs
4
+ * (Web Crypto SubtleCrypto, CryptoKit, javax.crypto).
5
+ *
6
+ * This module uses XOR + key-derived pad as a synchronous placeholder
7
+ * so that the public API (setData / getData) stays synchronous per spec.
8
+ *
9
+ * AUTH_TAG simulates AES-GCM's authentication: a known prefix is encrypted
10
+ * alongside the plaintext. On decrypt, if the prefix doesn't match,
11
+ * the key is wrong and an error is thrown.
12
+ */
13
+ export declare function encrypt(plaintext: string, key: string): string;
14
+ export declare function decrypt(base64: string, key: string): string;
package/dist/crypto.js ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Sync encryption for web runtime.
3
+ * Production implementations MUST use AES-256-GCM via platform crypto APIs
4
+ * (Web Crypto SubtleCrypto, CryptoKit, javax.crypto).
5
+ *
6
+ * This module uses XOR + key-derived pad as a synchronous placeholder
7
+ * so that the public API (setData / getData) stays synchronous per spec.
8
+ *
9
+ * AUTH_TAG simulates AES-GCM's authentication: a known prefix is encrypted
10
+ * alongside the plaintext. On decrypt, if the prefix doesn't match,
11
+ * the key is wrong and an error is thrown.
12
+ */
13
+ const AUTH_TAG = 'CS\x00\x01';
14
+ function deriveKeyBytes(key) {
15
+ const raw = new TextEncoder().encode(key);
16
+ const pad = new Uint8Array(256);
17
+ for (let i = 0; i < pad.length; i++) {
18
+ pad[i] = raw[i % raw.length] ^ ((i * 31 + 17) & 0xff);
19
+ }
20
+ return pad;
21
+ }
22
+ function xor(src, pad) {
23
+ const out = new Uint8Array(src.length);
24
+ for (let i = 0; i < src.length; i++) {
25
+ out[i] = src[i] ^ pad[i % pad.length];
26
+ }
27
+ return out;
28
+ }
29
+ export function encrypt(plaintext, key) {
30
+ const pad = deriveKeyBytes(key);
31
+ const src = new TextEncoder().encode(AUTH_TAG + plaintext);
32
+ const out = xor(src, pad);
33
+ return btoa(String.fromCharCode(...out));
34
+ }
35
+ export function decrypt(base64, key) {
36
+ const pad = deriveKeyBytes(key);
37
+ const src = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
38
+ const result = new TextDecoder().decode(xor(src, pad));
39
+ if (!result.startsWith(AUTH_TAG)) {
40
+ throw new Error('Decryption failed: invalid encryption key');
41
+ }
42
+ return result.slice(AUTH_TAG.length);
43
+ }
@@ -0,0 +1,3 @@
1
+ export { ContextStore } from './context-store';
2
+ export { Boundary, Storage } from './types';
3
+ export type { Envelope, ContextStoreOptions, Observable, Subscription, IContextStore, LogLevel, } from './types';
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { ContextStore } from './context-store';
2
+ export { Boundary, Storage } from './types';
@@ -0,0 +1,18 @@
1
+ import type { ContextStoreOptions } from './types';
2
+ export declare class ServerTimeManager {
3
+ private cachedTime;
4
+ private cachedAt;
5
+ private ttl;
6
+ private urls;
7
+ private timeout;
8
+ private delegate;
9
+ private log;
10
+ private fetchPromise;
11
+ constructor(options: ContextStoreOptions);
12
+ getServerTime(): Promise<Date>;
13
+ /** Synchronous best-effort: cached time or device time. Used internally for envelope timestamps. */
14
+ getNow(): Date;
15
+ private isCacheExpired;
16
+ private offsetFromCache;
17
+ private fetchFromServers;
18
+ }
@@ -0,0 +1,61 @@
1
+ export class ServerTimeManager {
2
+ cachedTime = null;
3
+ cachedAt = 0;
4
+ ttl;
5
+ urls;
6
+ timeout;
7
+ delegate;
8
+ log;
9
+ fetchPromise = null;
10
+ constructor(options) {
11
+ this.ttl = options.serverTimeTtl ?? 30 * 60 * 1000;
12
+ this.urls = options.timeServerUrls;
13
+ this.timeout = options.requestServerTimeTimeout ?? 5000;
14
+ this.delegate = options.onRequestServerTime;
15
+ this.log = options.onLog;
16
+ }
17
+ async getServerTime() {
18
+ if (this.cachedTime && !this.isCacheExpired()) {
19
+ return this.offsetFromCache();
20
+ }
21
+ try {
22
+ if (!this.fetchPromise) {
23
+ this.fetchPromise = this.fetchFromServers();
24
+ }
25
+ const time = await this.fetchPromise;
26
+ this.fetchPromise = null;
27
+ return time;
28
+ }
29
+ catch (err) {
30
+ this.fetchPromise = null;
31
+ this.log?.('error', 'Server time fetch failed, falling back to device time', err);
32
+ return new Date();
33
+ }
34
+ }
35
+ /** Synchronous best-effort: cached time or device time. Used internally for envelope timestamps. */
36
+ getNow() {
37
+ if (this.cachedTime && !this.isCacheExpired()) {
38
+ return this.offsetFromCache();
39
+ }
40
+ return new Date();
41
+ }
42
+ isCacheExpired() {
43
+ return performance.now() - this.cachedAt > this.ttl;
44
+ }
45
+ offsetFromCache() {
46
+ const elapsed = performance.now() - this.cachedAt;
47
+ return new Date(this.cachedTime.getTime() + elapsed);
48
+ }
49
+ async fetchFromServers() {
50
+ const results = await Promise.allSettled(this.urls.map((url) => this.delegate(url, this.timeout)));
51
+ for (const r of results) {
52
+ if (r.status === 'fulfilled' && r.value) {
53
+ this.cachedTime = r.value;
54
+ this.cachedAt = performance.now();
55
+ this.log?.('debug', 'Server time synced', undefined, { time: r.value.toISOString() });
56
+ return r.value;
57
+ }
58
+ }
59
+ throw new Error('All server time requests failed');
60
+ }
61
+ }
@@ -0,0 +1,106 @@
1
+ export declare enum Boundary {
2
+ device = "device",
3
+ user = "user",
4
+ subject = "subject"
5
+ }
6
+ export declare enum Storage {
7
+ secureStorage = "secureStorage",
8
+ secureStorageEncrypted = "secureStorageEncrypted",
9
+ memory = "memory",
10
+ localStorage = "localStorage"
11
+ }
12
+ export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
13
+ export type Subscription = {
14
+ unsubscribe(): void;
15
+ };
16
+ export type Observable<T> = {
17
+ subscribe(callback: (value: T) => void): Subscription;
18
+ };
19
+ export type Envelope = {
20
+ data: any;
21
+ expiry: string | null;
22
+ createdAt: string;
23
+ updatedAt: string;
24
+ appName: string;
25
+ appVersion: string;
26
+ sdkVersion: string;
27
+ };
28
+ export type ContextStoreOptions = {
29
+ onRequestServerTime: (url: string, timeout: number) => Promise<Date | null>;
30
+ timeServerUrls: string[];
31
+ serverTimeTtl?: number;
32
+ requestServerTimeTimeout?: number;
33
+ onLog?: (level: LogLevel, message: string, error?: any, context?: Record<string, any>) => void;
34
+ appName?: string;
35
+ appVersion?: string;
36
+ };
37
+ export interface IContextStore {
38
+ readonly activeDevice: string | null;
39
+ activeUser: string | null;
40
+ activeSubject: string | null;
41
+ getServerTime(): Promise<Date>;
42
+ setEncryptionKey(key: string): void;
43
+ get isEncryptionKeySet(): boolean;
44
+ revokeEncryptionKey(): void;
45
+ setData(boundary: Boundary, key: string, value: any, options?: {
46
+ storage?: Storage;
47
+ ttl?: number;
48
+ dataPath?: string;
49
+ }): void;
50
+ getData<T = any>(boundary: Boundary, key: string, options?: {
51
+ storage?: Storage;
52
+ dataPath?: string;
53
+ }): T | undefined;
54
+ getDataMetadata(boundary: Boundary, key: string, options?: {
55
+ storage?: Storage;
56
+ }): Envelope | undefined;
57
+ deleteData(boundary: Boundary, key: string, options?: {
58
+ storage?: Storage;
59
+ dataPath?: string;
60
+ }): void;
61
+ batchSet(operations: Array<{
62
+ boundary: Boundary;
63
+ key: string;
64
+ value: any;
65
+ storage?: Storage;
66
+ ttl?: number;
67
+ }>): void;
68
+ batchGet(operations: Array<{
69
+ boundary: Boundary;
70
+ key: string;
71
+ storage?: Storage;
72
+ }>): Array<{
73
+ boundary: Boundary;
74
+ key: string;
75
+ value: any;
76
+ }>;
77
+ observeData(boundary: Boundary, key: string, options?: {
78
+ storage?: Storage;
79
+ dataPath?: string;
80
+ }): Observable<any>;
81
+ addListener(listenerId: string, boundary: Boundary, key: string, callback: (value: any) => void, options?: {
82
+ storage?: Storage;
83
+ dataPath?: string;
84
+ }): void;
85
+ removeListener(listenerId: string): void;
86
+ clearAllListeners(): void;
87
+ findKeys(boundary: Boundary, partialKey: string, options?: {
88
+ storage?: Storage;
89
+ }): string[];
90
+ clearData(boundary: Boundary, options?: {
91
+ storage?: Storage;
92
+ partialKey?: string;
93
+ }): void;
94
+ exportData(boundary: Boundary, options?: {
95
+ storage?: Storage;
96
+ partialKey?: string;
97
+ }): Record<string, any>;
98
+ importData(boundary: Boundary, data: Record<string, any>, options?: {
99
+ storage?: Storage;
100
+ overwrite?: boolean;
101
+ }): void;
102
+ cleanup(options?: {
103
+ boundary?: Boundary;
104
+ storage?: Storage;
105
+ }): void;
106
+ }
package/dist/types.js ADDED
@@ -0,0 +1,13 @@
1
+ export var Boundary;
2
+ (function (Boundary) {
3
+ Boundary["device"] = "device";
4
+ Boundary["user"] = "user";
5
+ Boundary["subject"] = "subject";
6
+ })(Boundary || (Boundary = {}));
7
+ export var Storage;
8
+ (function (Storage) {
9
+ Storage["secureStorage"] = "secureStorage";
10
+ Storage["secureStorageEncrypted"] = "secureStorageEncrypted";
11
+ Storage["memory"] = "memory";
12
+ Storage["localStorage"] = "localStorage";
13
+ })(Storage || (Storage = {}));
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@burgantech/context-store",
3
+ "version": "0.1.0",
4
+ "description": "Centralized state store SDK — boundary-based, encrypted, observable",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "scripts": {
18
+ "build": "tsc",
19
+ "dev": "tsc --watch",
20
+ "test": "vitest run",
21
+ "test:watch": "vitest",
22
+ "prepublishOnly": "npm run build"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/burgan-tech/vnext-client-data-manager.git",
27
+ "directory": "core/ts-context-store"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public",
31
+ "registry": "https://registry.npmjs.org"
32
+ },
33
+ "license": "UNLICENSED",
34
+ "devDependencies": {
35
+ "happy-dom": "^20.8.4",
36
+ "typescript": "^5.4.0",
37
+ "vitest": "^3.0.0"
38
+ }
39
+ }