@newhomestar/sdk 0.8.0 → 0.8.1
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/dist/next.d.ts +4 -1
- package/dist/next.js +2 -0
- package/dist/userCache.d.ts +74 -0
- package/dist/userCache.js +210 -0
- package/package.json +58 -58
package/dist/next.d.ts
CHANGED
|
@@ -124,8 +124,9 @@ export interface NovaFgaCheckDef {
|
|
|
124
124
|
* Where to resolve object_id from in the request:
|
|
125
125
|
* 'path' → Next.js route params (e.g. params.id for /communities/:id)
|
|
126
126
|
* 'query' → URL query string param
|
|
127
|
+
* 'body' → JSON request body (for POST/PATCH endpoints where the id is in the body)
|
|
127
128
|
*/
|
|
128
|
-
object_id_from: 'path' | 'query';
|
|
129
|
+
object_id_from: 'path' | 'query' | 'body';
|
|
129
130
|
/** The param name to extract object_id from (e.g. 'id', 'community_id') */
|
|
130
131
|
object_id_param: string;
|
|
131
132
|
/** Optional tenant_id param name for multi-tenant scoped checks */
|
|
@@ -789,6 +790,8 @@ export interface ServiceDef {
|
|
|
789
790
|
export declare function defineService<T extends ServiceDef>(def: T): T & {
|
|
790
791
|
readonly __novaService: true;
|
|
791
792
|
};
|
|
793
|
+
export { UserCache, userCache } from './userCache.js';
|
|
794
|
+
export type { UserProfile, UserCacheOptions } from './userCache.js';
|
|
792
795
|
/** @deprecated Use `buildPageResponse` instead */
|
|
793
796
|
export declare function buildPaginatedResponse<T extends {
|
|
794
797
|
id: string;
|
package/dist/next.js
CHANGED
|
@@ -375,6 +375,8 @@ export function defineService(def) {
|
|
|
375
375
|
}
|
|
376
376
|
return { ...def, __novaService: true };
|
|
377
377
|
}
|
|
378
|
+
// ─── User Cache (re-export from userCache.ts) ────────────────────────────────
|
|
379
|
+
export { UserCache, userCache } from './userCache.js';
|
|
378
380
|
// ─── Legacy re-exports (backward compat) ─────────────────────────────────────
|
|
379
381
|
/** @deprecated Use `buildPageResponse` instead */
|
|
380
382
|
export function buildPaginatedResponse(data, count, pageSize) {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight user profile shape returned by the Auth Service internal endpoints.
|
|
3
|
+
* Used for display purposes (resolving `created_by` UUIDs to names/emails/avatars).
|
|
4
|
+
*/
|
|
5
|
+
export interface UserProfile {
|
|
6
|
+
id: string;
|
|
7
|
+
email: string | null;
|
|
8
|
+
display_name: string | null;
|
|
9
|
+
first_name: string | null;
|
|
10
|
+
last_name: string | null;
|
|
11
|
+
avatar_url: string | null;
|
|
12
|
+
external_id: number | null;
|
|
13
|
+
}
|
|
14
|
+
export interface UserCacheOptions {
|
|
15
|
+
/** Auth Service base URL. Defaults to AUTH_ISSUER_BASE_URL env var. */
|
|
16
|
+
authServiceURL?: string;
|
|
17
|
+
/** Service-to-service JWT. Defaults to NOVA_SERVICE_TOKEN env var. */
|
|
18
|
+
serviceToken?: string;
|
|
19
|
+
/** Auto-refresh interval in milliseconds. Default: 300_000 (5 min). */
|
|
20
|
+
refreshIntervalMs?: number;
|
|
21
|
+
/** Enable verbose debug logging. Default: true when NODE_ENV !== 'production'. */
|
|
22
|
+
debug?: boolean;
|
|
23
|
+
}
|
|
24
|
+
export declare class UserCache {
|
|
25
|
+
/** UUID → UserProfile */
|
|
26
|
+
private byId;
|
|
27
|
+
/** external_id (INT) → UUID */
|
|
28
|
+
private byExternalId;
|
|
29
|
+
private readonly authServiceURL;
|
|
30
|
+
private readonly serviceToken;
|
|
31
|
+
private readonly refreshIntervalMs;
|
|
32
|
+
private readonly _debug;
|
|
33
|
+
private timer;
|
|
34
|
+
private _lastRefreshed;
|
|
35
|
+
constructor(options?: UserCacheOptions);
|
|
36
|
+
private log;
|
|
37
|
+
/**
|
|
38
|
+
* Fetch all user profiles from the Auth Service and start the auto-refresh timer.
|
|
39
|
+
* Call once during service startup (e.g., in instrumentation.ts).
|
|
40
|
+
*/
|
|
41
|
+
start(): Promise<void>;
|
|
42
|
+
/** Stop the auto-refresh timer. Call during graceful shutdown. */
|
|
43
|
+
stop(): void;
|
|
44
|
+
/**
|
|
45
|
+
* Force an immediate full refresh from the Auth Service.
|
|
46
|
+
* Paginates through GET /api/internal/users/all until all pages are loaded,
|
|
47
|
+
* then atomically swaps the in-memory maps.
|
|
48
|
+
*/
|
|
49
|
+
refresh(): Promise<void>;
|
|
50
|
+
/** Get a user profile by platform UUID. Returns undefined on cache miss. */
|
|
51
|
+
getUser(uuid: string): UserProfile | undefined;
|
|
52
|
+
/** Get a user profile by legacy external_id (INT). */
|
|
53
|
+
getUserByExternalId(extId: number): UserProfile | undefined;
|
|
54
|
+
/**
|
|
55
|
+
* Resolve multiple UUIDs at once. Returns a Map of found profiles.
|
|
56
|
+
*
|
|
57
|
+
* By default, cache misses trigger an HTTP fallback to
|
|
58
|
+
* POST /api/internal/users/resolve on the Auth Service, which backfills
|
|
59
|
+
* the in-memory cache. Set `fetchMissing: false` to skip the HTTP call.
|
|
60
|
+
*/
|
|
61
|
+
resolveMany(uuids: string[], options?: {
|
|
62
|
+
fetchMissing?: boolean;
|
|
63
|
+
}): Promise<Map<string, UserProfile>>;
|
|
64
|
+
/** Check if a UUID exists in the cache. */
|
|
65
|
+
has(uuid: string): boolean;
|
|
66
|
+
/** Cache statistics for health checks and monitoring. */
|
|
67
|
+
get stats(): {
|
|
68
|
+
userCount: number;
|
|
69
|
+
externalIdCount: number;
|
|
70
|
+
lastRefreshed: Date | null;
|
|
71
|
+
isRunning: boolean;
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
export declare const userCache: UserCache;
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// ── User Profile Cache ────────────────────────────────────────────────────────
|
|
2
|
+
// In-memory cache of platform user profiles, loaded from the Auth Service.
|
|
3
|
+
// Mirrors the IAMPermissionCache pattern — startup warm-up, periodic refresh,
|
|
4
|
+
// and HTTP fallback for cache misses.
|
|
5
|
+
//
|
|
6
|
+
// Startup: userCache.start() (called from instrumentation.ts in consuming services)
|
|
7
|
+
// Usage: userCache.getUser(uuid) → UserProfile | undefined
|
|
8
|
+
// userCache.getUserByExternalId(n) → UserProfile | undefined
|
|
9
|
+
// userCache.resolveMany(uuids) → Map<uuid, UserProfile>
|
|
10
|
+
// userCache.stats → { userCount, lastRefreshed, isRunning }
|
|
11
|
+
//
|
|
12
|
+
// Reads: GET /api/internal/users/all on AUTH_ISSUER_BASE_URL
|
|
13
|
+
// POST /api/internal/users/resolve on AUTH_ISSUER_BASE_URL
|
|
14
|
+
// Auth: Bearer NOVA_SERVICE_TOKEN
|
|
15
|
+
// Refresh: Every USER_CACHE_REFRESH_MS (default: 5 minutes)
|
|
16
|
+
//
|
|
17
|
+
// See directory service .nova/user_service.md for full architecture docs.
|
|
18
|
+
// ── Class ─────────────────────────────────────────────────────────────────────
|
|
19
|
+
export class UserCache {
|
|
20
|
+
/** UUID → UserProfile */
|
|
21
|
+
byId = new Map();
|
|
22
|
+
/** external_id (INT) → UUID */
|
|
23
|
+
byExternalId = new Map();
|
|
24
|
+
authServiceURL;
|
|
25
|
+
serviceToken;
|
|
26
|
+
refreshIntervalMs;
|
|
27
|
+
_debug;
|
|
28
|
+
timer = null;
|
|
29
|
+
_lastRefreshed = null;
|
|
30
|
+
constructor(options = {}) {
|
|
31
|
+
this.authServiceURL = (options.authServiceURL ??
|
|
32
|
+
process.env.AUTH_ISSUER_BASE_URL ??
|
|
33
|
+
'').replace(/\/+$/, '');
|
|
34
|
+
this.serviceToken =
|
|
35
|
+
options.serviceToken ??
|
|
36
|
+
process.env.NOVA_SERVICE_TOKEN ??
|
|
37
|
+
'';
|
|
38
|
+
this.refreshIntervalMs =
|
|
39
|
+
options.refreshIntervalMs ??
|
|
40
|
+
parseInt(process.env.USER_CACHE_REFRESH_MS ?? '300000', 10);
|
|
41
|
+
this._debug = options.debug ?? (process.env.NODE_ENV !== 'production');
|
|
42
|
+
}
|
|
43
|
+
log(msg) {
|
|
44
|
+
if (this._debug)
|
|
45
|
+
console.log(`[UserCache] ${msg}`);
|
|
46
|
+
}
|
|
47
|
+
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
48
|
+
/**
|
|
49
|
+
* Fetch all user profiles from the Auth Service and start the auto-refresh timer.
|
|
50
|
+
* Call once during service startup (e.g., in instrumentation.ts).
|
|
51
|
+
*/
|
|
52
|
+
async start() {
|
|
53
|
+
if (!this.authServiceURL) {
|
|
54
|
+
this.log('⚠️ AUTH_ISSUER_BASE_URL not set — user cache disabled');
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (!this.serviceToken) {
|
|
58
|
+
this.log('⚠️ NOVA_SERVICE_TOKEN not set — user cache disabled');
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
this.log('🚀 Starting user cache...');
|
|
62
|
+
await this.refresh();
|
|
63
|
+
this.timer = setInterval(() => {
|
|
64
|
+
this.log('⏱ Auto-refresh triggered');
|
|
65
|
+
this.refresh().catch((err) => {
|
|
66
|
+
this.log(`⚠️ Auto-refresh failed: ${err.message}`);
|
|
67
|
+
console.error('[UserCache] ❌', err.message);
|
|
68
|
+
});
|
|
69
|
+
}, this.refreshIntervalMs);
|
|
70
|
+
// Allow the Node.js process to exit even if the timer is still pending
|
|
71
|
+
if (this.timer.unref)
|
|
72
|
+
this.timer.unref();
|
|
73
|
+
const nextMin = Math.round(this.refreshIntervalMs / 1000 / 60);
|
|
74
|
+
this.log(`✅ User cache started — ${this.byId.size} user(s) loaded, next refresh in ${nextMin}m`);
|
|
75
|
+
}
|
|
76
|
+
/** Stop the auto-refresh timer. Call during graceful shutdown. */
|
|
77
|
+
stop() {
|
|
78
|
+
if (this.timer) {
|
|
79
|
+
clearInterval(this.timer);
|
|
80
|
+
this.timer = null;
|
|
81
|
+
this.log('🛑 Auto-refresh stopped');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Force an immediate full refresh from the Auth Service.
|
|
86
|
+
* Paginates through GET /api/internal/users/all until all pages are loaded,
|
|
87
|
+
* then atomically swaps the in-memory maps.
|
|
88
|
+
*/
|
|
89
|
+
async refresh() {
|
|
90
|
+
const newById = new Map();
|
|
91
|
+
const newByExtId = new Map();
|
|
92
|
+
const startMs = Date.now();
|
|
93
|
+
let page = 1;
|
|
94
|
+
// Paginate through all users
|
|
95
|
+
while (true) {
|
|
96
|
+
const url = `${this.authServiceURL}/api/internal/users/all?page=${page}&per_page=1000`;
|
|
97
|
+
this.log(`🔄 Fetching page ${page} from ${url}...`);
|
|
98
|
+
const res = await fetch(url, {
|
|
99
|
+
headers: {
|
|
100
|
+
'Content-Type': 'application/json',
|
|
101
|
+
Authorization: `Bearer ${this.serviceToken}`,
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
if (!res.ok) {
|
|
105
|
+
const errMsg = `refresh() failed on page ${page}: ${res.status} ${res.statusText}`;
|
|
106
|
+
this.log(`❌ ${errMsg}`);
|
|
107
|
+
throw new Error(errMsg);
|
|
108
|
+
}
|
|
109
|
+
const json = (await res.json());
|
|
110
|
+
for (const user of json.users) {
|
|
111
|
+
newById.set(user.id, user);
|
|
112
|
+
if (user.external_id != null) {
|
|
113
|
+
newByExtId.set(user.external_id, user.id);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (json.next_page == null)
|
|
117
|
+
break;
|
|
118
|
+
page = json.next_page;
|
|
119
|
+
}
|
|
120
|
+
// Atomically swap the cache
|
|
121
|
+
this.byId = newById;
|
|
122
|
+
this.byExternalId = newByExtId;
|
|
123
|
+
this._lastRefreshed = new Date();
|
|
124
|
+
const elapsedMs = Date.now() - startMs;
|
|
125
|
+
this.log(`✅ Loaded ${newById.size} user(s), ${newByExtId.size} external ID mapping(s) in ${elapsedMs}ms`);
|
|
126
|
+
}
|
|
127
|
+
// ── Query Methods ─────────────────────────────────────────────────────────
|
|
128
|
+
/** Get a user profile by platform UUID. Returns undefined on cache miss. */
|
|
129
|
+
getUser(uuid) {
|
|
130
|
+
return this.byId.get(uuid);
|
|
131
|
+
}
|
|
132
|
+
/** Get a user profile by legacy external_id (INT). */
|
|
133
|
+
getUserByExternalId(extId) {
|
|
134
|
+
const uuid = this.byExternalId.get(extId);
|
|
135
|
+
return uuid ? this.byId.get(uuid) : undefined;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Resolve multiple UUIDs at once. Returns a Map of found profiles.
|
|
139
|
+
*
|
|
140
|
+
* By default, cache misses trigger an HTTP fallback to
|
|
141
|
+
* POST /api/internal/users/resolve on the Auth Service, which backfills
|
|
142
|
+
* the in-memory cache. Set `fetchMissing: false` to skip the HTTP call.
|
|
143
|
+
*/
|
|
144
|
+
async resolveMany(uuids, options) {
|
|
145
|
+
const result = new Map();
|
|
146
|
+
const missing = [];
|
|
147
|
+
for (const uuid of uuids) {
|
|
148
|
+
const cached = this.byId.get(uuid);
|
|
149
|
+
if (cached) {
|
|
150
|
+
result.set(uuid, cached);
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
missing.push(uuid);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// HTTP fallback for cache misses
|
|
157
|
+
const shouldFetch = missing.length > 0 &&
|
|
158
|
+
options?.fetchMissing !== false &&
|
|
159
|
+
this.authServiceURL &&
|
|
160
|
+
this.serviceToken;
|
|
161
|
+
if (shouldFetch) {
|
|
162
|
+
try {
|
|
163
|
+
const res = await fetch(`${this.authServiceURL}/api/internal/users/resolve`, {
|
|
164
|
+
method: 'POST',
|
|
165
|
+
headers: {
|
|
166
|
+
'Content-Type': 'application/json',
|
|
167
|
+
Authorization: `Bearer ${this.serviceToken}`,
|
|
168
|
+
},
|
|
169
|
+
body: JSON.stringify({ ids: missing }),
|
|
170
|
+
});
|
|
171
|
+
if (res.ok) {
|
|
172
|
+
const json = (await res.json());
|
|
173
|
+
for (const [id, profile] of Object.entries(json.users)) {
|
|
174
|
+
result.set(id, profile);
|
|
175
|
+
// Backfill the in-memory cache so subsequent lookups are instant
|
|
176
|
+
this.byId.set(id, profile);
|
|
177
|
+
if (profile.external_id != null) {
|
|
178
|
+
this.byExternalId.set(profile.external_id, id);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
this.log(`🔄 Backfilled ${Object.keys(json.users).length} cache miss(es)`);
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
this.log(`⚠️ HTTP fallback failed: ${res.status} ${res.statusText}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
this.log(`⚠️ HTTP fallback error: ${err.message}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
/** Check if a UUID exists in the cache. */
|
|
194
|
+
has(uuid) {
|
|
195
|
+
return this.byId.has(uuid);
|
|
196
|
+
}
|
|
197
|
+
// ── Stats ─────────────────────────────────────────────────────────────────
|
|
198
|
+
/** Cache statistics for health checks and monitoring. */
|
|
199
|
+
get stats() {
|
|
200
|
+
return {
|
|
201
|
+
userCount: this.byId.size,
|
|
202
|
+
externalIdCount: this.byExternalId.size,
|
|
203
|
+
lastRefreshed: this._lastRefreshed,
|
|
204
|
+
isRunning: this.timer !== null,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// ── Singleton ─────────────────────────────────────────────────────────────────
|
|
209
|
+
// Module-level singleton — import and use directly from route handlers.
|
|
210
|
+
export const userCache = new UserCache();
|
package/package.json
CHANGED
|
@@ -1,58 +1,58 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@newhomestar/sdk",
|
|
3
|
-
"version": "0.8.
|
|
4
|
-
"description": "Type-safe SDK for building Nova pipelines (workers & functions)",
|
|
5
|
-
"homepage": "https://github.com/newhomestar/nova-node-sdk#readme",
|
|
6
|
-
"bugs": {
|
|
7
|
-
"url": "https://github.com/newhomestar/nova-node-sdk/issues"
|
|
8
|
-
},
|
|
9
|
-
"repository": {
|
|
10
|
-
"type": "git",
|
|
11
|
-
"url": "git+https://github.com/newhomestar/nova-node-sdk.git"
|
|
12
|
-
},
|
|
13
|
-
"license": "ISC",
|
|
14
|
-
"author": "Christian Gomez",
|
|
15
|
-
"type": "module",
|
|
16
|
-
"main": "dist/index.js",
|
|
17
|
-
"types": "dist/index.d.ts",
|
|
18
|
-
"exports": {
|
|
19
|
-
".": {
|
|
20
|
-
"import": "./dist/index.js",
|
|
21
|
-
"types": "./dist/index.d.ts"
|
|
22
|
-
},
|
|
23
|
-
"./next": {
|
|
24
|
-
"import": "./dist/next.js",
|
|
25
|
-
"types": "./dist/next.d.ts"
|
|
26
|
-
},
|
|
27
|
-
"./events": {
|
|
28
|
-
"import": "./dist/events.js",
|
|
29
|
-
"types": "./dist/events.d.ts"
|
|
30
|
-
}
|
|
31
|
-
},
|
|
32
|
-
"files": [
|
|
33
|
-
"dist"
|
|
34
|
-
],
|
|
35
|
-
"scripts": {
|
|
36
|
-
"build": "tsc"
|
|
37
|
-
},
|
|
38
|
-
"dependencies": {
|
|
39
|
-
"@openfga/sdk": "^0.9.0",
|
|
40
|
-
"@orpc/openapi": "1.7.4",
|
|
41
|
-
"@orpc/server": "1.7.4",
|
|
42
|
-
"@supabase/supabase-js": "^2.39.0",
|
|
43
|
-
"body-parser": "^1.20.2",
|
|
44
|
-
"dotenv": "^16.4.3",
|
|
45
|
-
"express": "^4.18.2",
|
|
46
|
-
"express-oauth2-jwt-bearer": "^1.7.4",
|
|
47
|
-
"undici": "^7.24.4",
|
|
48
|
-
"yaml": "^2.7.1"
|
|
49
|
-
},
|
|
50
|
-
"peerDependencies": {
|
|
51
|
-
"zod": ">=4.0.0"
|
|
52
|
-
},
|
|
53
|
-
"devDependencies": {
|
|
54
|
-
"@types/node": "^20.11.17",
|
|
55
|
-
"typescript": "^5.4.4",
|
|
56
|
-
"zod": "^4.3.0"
|
|
57
|
-
}
|
|
58
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@newhomestar/sdk",
|
|
3
|
+
"version": "0.8.1",
|
|
4
|
+
"description": "Type-safe SDK for building Nova pipelines (workers & functions)",
|
|
5
|
+
"homepage": "https://github.com/newhomestar/nova-node-sdk#readme",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/newhomestar/nova-node-sdk/issues"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/newhomestar/nova-node-sdk.git"
|
|
12
|
+
},
|
|
13
|
+
"license": "ISC",
|
|
14
|
+
"author": "Christian Gomez",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "dist/index.js",
|
|
17
|
+
"types": "dist/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"import": "./dist/index.js",
|
|
21
|
+
"types": "./dist/index.d.ts"
|
|
22
|
+
},
|
|
23
|
+
"./next": {
|
|
24
|
+
"import": "./dist/next.js",
|
|
25
|
+
"types": "./dist/next.d.ts"
|
|
26
|
+
},
|
|
27
|
+
"./events": {
|
|
28
|
+
"import": "./dist/events.js",
|
|
29
|
+
"types": "./dist/events.d.ts"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsc"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@openfga/sdk": "^0.9.0",
|
|
40
|
+
"@orpc/openapi": "1.7.4",
|
|
41
|
+
"@orpc/server": "1.7.4",
|
|
42
|
+
"@supabase/supabase-js": "^2.39.0",
|
|
43
|
+
"body-parser": "^1.20.2",
|
|
44
|
+
"dotenv": "^16.4.3",
|
|
45
|
+
"express": "^4.18.2",
|
|
46
|
+
"express-oauth2-jwt-bearer": "^1.7.4",
|
|
47
|
+
"undici": "^7.24.4",
|
|
48
|
+
"yaml": "^2.7.1"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"zod": ">=4.0.0"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/node": "^20.11.17",
|
|
55
|
+
"typescript": "^5.4.4",
|
|
56
|
+
"zod": "^4.3.0"
|
|
57
|
+
}
|
|
58
|
+
}
|