@stardeck-customer-apps/controls 0.0.0-stage → 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.
- package/SKILL.md +129 -0
- package/dist/index.cjs +105 -0
- package/dist/index.d.cts +103 -0
- package/dist/index.d.ts +103 -0
- package/dist/index.js +73 -0
- package/dist/server/index.cjs +420 -0
- package/dist/server/index.d.cts +138 -0
- package/dist/server/index.d.ts +138 -0
- package/dist/server/index.js +387 -0
- package/package.json +64 -4
- package/README.md +0 -3
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
// src/controls.ts
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
var CONTROLS = {
|
|
4
|
+
"flag.paymentsSetupStep": { schema: z.boolean(), appWrite: false },
|
|
5
|
+
"plan.locations": {
|
|
6
|
+
schema: z.object({
|
|
7
|
+
/** Locations the plan includes. null = agreed per deal. */
|
|
8
|
+
included: z.number().int().positive().nullable(),
|
|
9
|
+
/** Hard cap. Platform plan: 1; others: null (no cap, extras are billed). */
|
|
10
|
+
max: z.number().int().positive().nullable(),
|
|
11
|
+
/** Monthly price of each location past `included`. null = not offered. */
|
|
12
|
+
extraMonthly: z.object({
|
|
13
|
+
amount: z.number().nonnegative(),
|
|
14
|
+
currency: z.enum(["THB", "USD"])
|
|
15
|
+
}).nullable()
|
|
16
|
+
}),
|
|
17
|
+
appWrite: false
|
|
18
|
+
},
|
|
19
|
+
"plan.multiEntity": { schema: z.boolean(), appWrite: false },
|
|
20
|
+
"payments.provider": { schema: z.enum(["beam", "stripe"]).nullable(), appWrite: true },
|
|
21
|
+
"payments.methods": {
|
|
22
|
+
schema: z.object({ card: z.boolean(), promptPay: z.boolean() }),
|
|
23
|
+
appWrite: true
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
var CONTROL_KEYS = Object.keys(CONTROLS);
|
|
27
|
+
function parseOne(entry, schema) {
|
|
28
|
+
if (typeof entry !== "object" || entry === null) {
|
|
29
|
+
return { ok: false, reason: "not provided by the platform" };
|
|
30
|
+
}
|
|
31
|
+
const record = entry;
|
|
32
|
+
if (record.ok === false) {
|
|
33
|
+
return {
|
|
34
|
+
ok: false,
|
|
35
|
+
reason: typeof record.reason === "string" ? record.reason : "unavailable"
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
if (record.ok !== true) {
|
|
39
|
+
return { ok: false, reason: "malformed control" };
|
|
40
|
+
}
|
|
41
|
+
const parsed = schema.safeParse(record.value);
|
|
42
|
+
if (!parsed.success) {
|
|
43
|
+
return { ok: false, reason: `invalid value: ${parsed.error.issues[0]?.message ?? "unknown"}` };
|
|
44
|
+
}
|
|
45
|
+
return { ok: true, value: parsed.data };
|
|
46
|
+
}
|
|
47
|
+
function parseControls(raw) {
|
|
48
|
+
const source = typeof raw === "object" && raw !== null && !Array.isArray(raw) ? raw : {};
|
|
49
|
+
const result = {};
|
|
50
|
+
for (const key of CONTROL_KEYS) {
|
|
51
|
+
result[key] = parseOne(source[key], CONTROLS[key].schema);
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
function unavailableControls(reason) {
|
|
56
|
+
const result = {};
|
|
57
|
+
for (const key of CONTROL_KEYS) result[key] = { ok: false, reason };
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// src/server/client.ts
|
|
62
|
+
import { signDeploymentRequest } from "@stardeck-customer-apps/kernel/server";
|
|
63
|
+
|
|
64
|
+
// src/server/state.ts
|
|
65
|
+
var CONTROLS_STATE_SYMBOL = /* @__PURE__ */ Symbol.for("stardeck.controls.state");
|
|
66
|
+
function createControlsState() {
|
|
67
|
+
const state = {
|
|
68
|
+
cache: null,
|
|
69
|
+
inflight: null,
|
|
70
|
+
failure: null,
|
|
71
|
+
generation: 0,
|
|
72
|
+
usageProvider: null,
|
|
73
|
+
configKey: null,
|
|
74
|
+
warnedNotConfigured: false,
|
|
75
|
+
reset() {
|
|
76
|
+
state.cache = null;
|
|
77
|
+
state.inflight = null;
|
|
78
|
+
state.failure = null;
|
|
79
|
+
state.generation += 1;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
return state;
|
|
83
|
+
}
|
|
84
|
+
function getGlobalControlsState() {
|
|
85
|
+
const holder = globalThis;
|
|
86
|
+
holder[CONTROLS_STATE_SYMBOL] ??= createControlsState();
|
|
87
|
+
return holder[CONTROLS_STATE_SYMBOL];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// src/server/client.ts
|
|
91
|
+
var DEFAULT_TTL_MS = 6e4;
|
|
92
|
+
var FAILURE_BACKOFF_MS = 5e3;
|
|
93
|
+
var DEFAULT_STALE_TIMEOUT_MS = 1500;
|
|
94
|
+
var DEFAULT_SYNC_TIMEOUT_MS = 3e3;
|
|
95
|
+
var DEFAULT_WRITE_TIMEOUT_MS = 1e4;
|
|
96
|
+
var USAGE_TIMEOUT_MS = 1e3;
|
|
97
|
+
var USER_TOKEN_HEADER = "X-Stardeck-User-Token";
|
|
98
|
+
var WRITE_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
99
|
+
"NOT_WRITABLE",
|
|
100
|
+
"INVALID_VALUE",
|
|
101
|
+
"UNAUTHENTICATED",
|
|
102
|
+
"FORBIDDEN",
|
|
103
|
+
"WRITE_NOT_ALLOWED_HERE",
|
|
104
|
+
"PROVIDER_NOT_READY",
|
|
105
|
+
"UNAVAILABLE"
|
|
106
|
+
]);
|
|
107
|
+
function describeError(error) {
|
|
108
|
+
return error instanceof Error ? error.message : String(error);
|
|
109
|
+
}
|
|
110
|
+
function isRecord(value) {
|
|
111
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
112
|
+
}
|
|
113
|
+
function sanitizeUsage(usage) {
|
|
114
|
+
const clean = {};
|
|
115
|
+
if (Number.isInteger(usage.locations) && usage.locations >= 0) {
|
|
116
|
+
clean.locations = usage.locations;
|
|
117
|
+
}
|
|
118
|
+
return clean;
|
|
119
|
+
}
|
|
120
|
+
function parseWriteError(status, body) {
|
|
121
|
+
const error = isRecord(body) && isRecord(body.error) ? body.error : null;
|
|
122
|
+
if (error && typeof error.code === "string" && WRITE_ERROR_CODES.has(error.code)) {
|
|
123
|
+
return {
|
|
124
|
+
code: error.code,
|
|
125
|
+
message: typeof error.message === "string" ? error.message : `Request failed (${status})`,
|
|
126
|
+
...typeof error.connectUrl === "string" ? { connectUrl: error.connectUrl } : {}
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
code: "UNAVAILABLE",
|
|
131
|
+
message: `The platform answered ${status} without a usable error`
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
async function readJson(response) {
|
|
135
|
+
try {
|
|
136
|
+
return await response.json();
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async function withTimeout(promise, ms) {
|
|
142
|
+
let timer;
|
|
143
|
+
const timeout = new Promise((_, reject) => {
|
|
144
|
+
timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms);
|
|
145
|
+
});
|
|
146
|
+
try {
|
|
147
|
+
return await Promise.race([promise, timeout]);
|
|
148
|
+
} finally {
|
|
149
|
+
clearTimeout(timer);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function createControlsClientWithState(config, state) {
|
|
153
|
+
const doFetch = config.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
154
|
+
const now = config.now ?? Date.now;
|
|
155
|
+
const ttlMs = config.ttlMs ?? DEFAULT_TTL_MS;
|
|
156
|
+
const staleTimeoutMs = config.staleTimeoutMs ?? DEFAULT_STALE_TIMEOUT_MS;
|
|
157
|
+
const syncTimeoutMs = config.requestTimeoutMs ?? DEFAULT_SYNC_TIMEOUT_MS;
|
|
158
|
+
const writeTimeoutMs = config.requestTimeoutMs ?? DEFAULT_WRITE_TIMEOUT_MS;
|
|
159
|
+
const usageTimeoutMs = Math.min(USAGE_TIMEOUT_MS, syncTimeoutMs);
|
|
160
|
+
const baseUrl = `${config.controlPlaneUrl.replace(/\/+$/, "")}/api/deployments/${encodeURIComponent(config.deploymentId)}/controls`;
|
|
161
|
+
function signedHeaders(extra) {
|
|
162
|
+
return {
|
|
163
|
+
"Content-Type": "application/json",
|
|
164
|
+
"X-Stardeck-Auth": signDeploymentRequest(config.deploymentSecret, {
|
|
165
|
+
organizationId: config.organizationId,
|
|
166
|
+
projectId: config.projectId,
|
|
167
|
+
deploymentId: config.deploymentId
|
|
168
|
+
}),
|
|
169
|
+
...extra
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
async function collectUsage() {
|
|
173
|
+
const provider = config.usage ?? state.usageProvider;
|
|
174
|
+
if (!provider) return void 0;
|
|
175
|
+
try {
|
|
176
|
+
return sanitizeUsage(await withTimeout(Promise.resolve().then(provider), usageTimeoutMs));
|
|
177
|
+
} catch (error) {
|
|
178
|
+
console.error(
|
|
179
|
+
`[controls] usage provider failed, syncing without usage: ${describeError(error)}`
|
|
180
|
+
);
|
|
181
|
+
return void 0;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
async function fetchControls() {
|
|
185
|
+
const usage = await collectUsage();
|
|
186
|
+
const body = usage ? { usage } : {};
|
|
187
|
+
const response = await doFetch(baseUrl, {
|
|
188
|
+
method: "POST",
|
|
189
|
+
headers: signedHeaders(),
|
|
190
|
+
body: JSON.stringify(body),
|
|
191
|
+
signal: AbortSignal.timeout(syncTimeoutMs)
|
|
192
|
+
});
|
|
193
|
+
if (!response.ok) {
|
|
194
|
+
throw new Error(`the platform answered ${response.status}`);
|
|
195
|
+
}
|
|
196
|
+
const json = await readJson(response);
|
|
197
|
+
if (!isRecord(json) || !isRecord(json.controls)) {
|
|
198
|
+
throw new Error("the platform answered without a controls map");
|
|
199
|
+
}
|
|
200
|
+
return parseControls(json.controls);
|
|
201
|
+
}
|
|
202
|
+
function recordFailure(generation, reason) {
|
|
203
|
+
if (generation !== state.generation) return;
|
|
204
|
+
const until = now() + FAILURE_BACKOFF_MS;
|
|
205
|
+
if (state.cache) {
|
|
206
|
+
state.cache.expiresAt = until;
|
|
207
|
+
} else {
|
|
208
|
+
state.failure = { until, reason };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
async function runSync() {
|
|
212
|
+
const generation = state.generation;
|
|
213
|
+
try {
|
|
214
|
+
const controls = await fetchControls();
|
|
215
|
+
if (generation === state.generation) {
|
|
216
|
+
state.cache = { controls, expiresAt: now() + ttlMs };
|
|
217
|
+
state.failure = null;
|
|
218
|
+
}
|
|
219
|
+
return controls;
|
|
220
|
+
} catch (error) {
|
|
221
|
+
const reason = `controls sync failed: ${describeError(error)}`;
|
|
222
|
+
console.error(`[controls] refresh failed deployment=${config.deploymentId} ${reason}`);
|
|
223
|
+
recordFailure(generation, reason);
|
|
224
|
+
throw new Error(reason);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
function startSync() {
|
|
228
|
+
if (state.inflight) return state.inflight;
|
|
229
|
+
const promise = runSync();
|
|
230
|
+
state.inflight = promise;
|
|
231
|
+
const clear = () => {
|
|
232
|
+
if (state.inflight === promise) state.inflight = null;
|
|
233
|
+
};
|
|
234
|
+
promise.then(clear, clear);
|
|
235
|
+
return promise;
|
|
236
|
+
}
|
|
237
|
+
async function load() {
|
|
238
|
+
const current = now();
|
|
239
|
+
const cache = state.cache;
|
|
240
|
+
if (cache && current < cache.expiresAt) return cache.controls;
|
|
241
|
+
if (!cache && state.failure && current < state.failure.until) {
|
|
242
|
+
return unavailableControls(state.failure.reason);
|
|
243
|
+
}
|
|
244
|
+
const sync = startSync();
|
|
245
|
+
if (!cache) {
|
|
246
|
+
try {
|
|
247
|
+
return await sync;
|
|
248
|
+
} catch (error) {
|
|
249
|
+
return unavailableControls(describeError(error));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
return await withTimeout(sync, staleTimeoutMs);
|
|
254
|
+
} catch (error) {
|
|
255
|
+
console.log(
|
|
256
|
+
`[controls] serving last value deployment=${config.deploymentId} (${describeError(error)})`
|
|
257
|
+
);
|
|
258
|
+
return state.cache?.controls ?? cache.controls;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
async function write(key, value, userToken) {
|
|
262
|
+
const body = { value };
|
|
263
|
+
let response;
|
|
264
|
+
try {
|
|
265
|
+
response = await doFetch(`${baseUrl}/${encodeURIComponent(key)}`, {
|
|
266
|
+
method: "PUT",
|
|
267
|
+
headers: signedHeaders({ [USER_TOKEN_HEADER]: userToken }),
|
|
268
|
+
body: JSON.stringify(body),
|
|
269
|
+
signal: AbortSignal.timeout(writeTimeoutMs)
|
|
270
|
+
});
|
|
271
|
+
} catch (error) {
|
|
272
|
+
console.error(`[controls] write failed key=${key}: ${describeError(error)}`);
|
|
273
|
+
return { ok: false, error: { code: "UNAVAILABLE", message: describeError(error) } };
|
|
274
|
+
}
|
|
275
|
+
const json = await readJson(response);
|
|
276
|
+
if (!response.ok) {
|
|
277
|
+
const error = parseWriteError(response.status, json);
|
|
278
|
+
console.log(
|
|
279
|
+
`[controls] write refused key=${key} status=${response.status} code=${error.code}`
|
|
280
|
+
);
|
|
281
|
+
return { ok: false, error };
|
|
282
|
+
}
|
|
283
|
+
if (!isRecord(json) || !isRecord(json.controls)) {
|
|
284
|
+
console.error(`[controls] write answered without a controls map key=${key}`);
|
|
285
|
+
return {
|
|
286
|
+
ok: false,
|
|
287
|
+
error: { code: "UNAVAILABLE", message: "The platform answered without the new controls" }
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
const controls = parseControls(json.controls);
|
|
291
|
+
state.generation += 1;
|
|
292
|
+
state.cache = { controls, expiresAt: now() + ttlMs };
|
|
293
|
+
state.failure = null;
|
|
294
|
+
console.log(`[controls] write applied key=${key}`);
|
|
295
|
+
return { ok: true, controls };
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
async get(key) {
|
|
299
|
+
return (await load())[key];
|
|
300
|
+
},
|
|
301
|
+
getAll: load,
|
|
302
|
+
async set(key, value, opts) {
|
|
303
|
+
return write(key, value, opts.userToken);
|
|
304
|
+
},
|
|
305
|
+
async refresh() {
|
|
306
|
+
try {
|
|
307
|
+
return await startSync();
|
|
308
|
+
} catch (error) {
|
|
309
|
+
return state.cache?.controls ?? unavailableControls(describeError(error));
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
function createControlsClient(config) {
|
|
315
|
+
return createControlsClientWithState(config, createControlsState());
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// src/server/index.ts
|
|
319
|
+
var NOT_CONFIGURED = "not configured";
|
|
320
|
+
function readEnvConfig() {
|
|
321
|
+
const controlPlaneUrl = process.env.CONTROL_PLANE_URL;
|
|
322
|
+
const organizationId = process.env.ORGANIZATION_ID;
|
|
323
|
+
const projectId = process.env.PROJECT_ID;
|
|
324
|
+
const deploymentId = process.env.DEPLOYMENT_ID;
|
|
325
|
+
const deploymentSecret = process.env.DEPLOYMENT_SECRET;
|
|
326
|
+
if (!controlPlaneUrl || !organizationId || !projectId || !deploymentId || !deploymentSecret) {
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
return { controlPlaneUrl, organizationId, projectId, deploymentId, deploymentSecret };
|
|
330
|
+
}
|
|
331
|
+
function envClient() {
|
|
332
|
+
const state = getGlobalControlsState();
|
|
333
|
+
const config = readEnvConfig();
|
|
334
|
+
if (!config) {
|
|
335
|
+
if (!state.warnedNotConfigured) {
|
|
336
|
+
state.warnedNotConfigured = true;
|
|
337
|
+
console.warn(
|
|
338
|
+
"[controls] not configured: CONTROL_PLANE_URL, ORGANIZATION_ID, PROJECT_ID, DEPLOYMENT_ID and DEPLOYMENT_SECRET are required; every control reads as unavailable"
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
const configKey = `${config.controlPlaneUrl}|${config.deploymentId}|${config.deploymentSecret}`;
|
|
344
|
+
if (state.configKey !== configKey) {
|
|
345
|
+
state.reset();
|
|
346
|
+
state.configKey = configKey;
|
|
347
|
+
}
|
|
348
|
+
return createControlsClientWithState(config, state);
|
|
349
|
+
}
|
|
350
|
+
async function getControl(key) {
|
|
351
|
+
const client = envClient();
|
|
352
|
+
if (!client) return { ok: false, reason: NOT_CONFIGURED };
|
|
353
|
+
return client.get(key);
|
|
354
|
+
}
|
|
355
|
+
async function getControls() {
|
|
356
|
+
const client = envClient();
|
|
357
|
+
return client ? client.getAll() : unavailableControls(NOT_CONFIGURED);
|
|
358
|
+
}
|
|
359
|
+
async function refreshControls() {
|
|
360
|
+
const client = envClient();
|
|
361
|
+
return client ? client.refresh() : unavailableControls(NOT_CONFIGURED);
|
|
362
|
+
}
|
|
363
|
+
async function setControl(key, value, opts) {
|
|
364
|
+
const client = envClient();
|
|
365
|
+
if (!client) {
|
|
366
|
+
return {
|
|
367
|
+
ok: false,
|
|
368
|
+
error: { code: "UNAVAILABLE", message: "Controls are not configured for this app" }
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
return client.set(key, value, opts);
|
|
372
|
+
}
|
|
373
|
+
function setUsageProvider(fn) {
|
|
374
|
+
getGlobalControlsState().usageProvider = fn;
|
|
375
|
+
}
|
|
376
|
+
function resetControlsState() {
|
|
377
|
+
getGlobalControlsState().reset();
|
|
378
|
+
}
|
|
379
|
+
export {
|
|
380
|
+
createControlsClient,
|
|
381
|
+
getControl,
|
|
382
|
+
getControls,
|
|
383
|
+
refreshControls,
|
|
384
|
+
resetControlsState,
|
|
385
|
+
setControl,
|
|
386
|
+
setUsageProvider
|
|
387
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,66 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stardeck-customer-apps/controls",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "Platform-owned app controls (feature flags, plan limits, payment provider and methods) for Stardeck customer apps: a typed list plus a cached, signed sync client",
|
|
6
|
+
"main": "dist/index.cjs",
|
|
7
|
+
"module": "dist/index.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"publishConfig": {
|
|
10
|
+
"registry": "https://registry.npmjs.org/",
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"SKILL.md"
|
|
16
|
+
],
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"import": {
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"default": "./dist/index.js"
|
|
22
|
+
},
|
|
23
|
+
"require": {
|
|
24
|
+
"types": "./dist/index.d.cts",
|
|
25
|
+
"default": "./dist/index.cjs"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"./server": {
|
|
29
|
+
"import": {
|
|
30
|
+
"types": "./dist/server/index.d.ts",
|
|
31
|
+
"default": "./dist/server/index.js"
|
|
32
|
+
},
|
|
33
|
+
"require": {
|
|
34
|
+
"types": "./dist/server/index.d.cts",
|
|
35
|
+
"default": "./dist/server/index.cjs"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsup",
|
|
41
|
+
"dev": "tsup --watch",
|
|
42
|
+
"format": "prettier --write . && oxlint . --fix",
|
|
43
|
+
"typecheck": "node ../../scripts/typecheck.mjs --noEmit",
|
|
44
|
+
"lint": "oxlint src/",
|
|
45
|
+
"test": "vitest run"
|
|
46
|
+
},
|
|
47
|
+
"keywords": [
|
|
48
|
+
"stardeck",
|
|
49
|
+
"controls",
|
|
50
|
+
"feature-flags",
|
|
51
|
+
"plan-limits"
|
|
52
|
+
],
|
|
53
|
+
"author": "Stardeck",
|
|
54
|
+
"license": "MIT",
|
|
55
|
+
"dependencies": {
|
|
56
|
+
"@stardeck-customer-apps/kernel": "^0.1.0",
|
|
57
|
+
"zod": "^4.1.12"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@stardeck-customer-apps/tsconfig": "*",
|
|
61
|
+
"@types/node": "^24.10.1",
|
|
62
|
+
"tsup": "^8.0.0",
|
|
63
|
+
"typescript": "^5.0.0",
|
|
64
|
+
"vitest": "^3.2.4"
|
|
65
|
+
}
|
|
66
|
+
}
|
package/README.md
DELETED