@xiashe/sdk 0.1.36 → 0.1.38

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/http.mjs ADDED
@@ -0,0 +1,37 @@
1
+ const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
2
+ export function requestUrl(value) {
3
+ const url = new URL(value);
4
+ const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
5
+ if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) || url.username || url.password || url.hash) throw Error('HTTP_TARGET_INVALID');
6
+ return url;
7
+ }
8
+ export async function boundedJsonRequest(url, body, { timeoutMs = 20000, headers = {}, maxResponseBytes = MAX_RESPONSE_BYTES, method = 'POST' } = {}) {
9
+ requestUrl(url);
10
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || !Number.isSafeInteger(maxResponseBytes) || maxResponseBytes <= 0) throw Error('HTTP_LIMIT_INVALID');
11
+ const controller = new AbortController();
12
+ let timer;
13
+ const deadline = new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(Error('REQUEST_TIMEOUT')); }, timeoutMs); });
14
+ const request = async () => {
15
+ const response = await fetch(url, { method, redirect: 'error', signal: controller.signal,
16
+ headers: { 'content-type': 'application/json', ...headers }, ...(method === 'GET' ? {} : { body: JSON.stringify(body ?? {}) }) });
17
+ if (response.status >= 300 && response.status < 400) throw Error('HTTP_REDIRECT_REFUSED');
18
+ if (Number(response.headers.get('content-length')) > maxResponseBytes) { controller.abort(); throw Error('HTTP_RESPONSE_TOO_LARGE'); }
19
+ const reader = response.body?.getReader();
20
+ const chunks = []; let total = 0;
21
+ try {
22
+ if (reader) while (true) {
23
+ const { done, value } = await reader.read(); if (done) break;
24
+ total += value.byteLength;
25
+ if (total > maxResponseBytes) { controller.abort(); throw Error('HTTP_RESPONSE_TOO_LARGE'); }
26
+ chunks.push(Buffer.from(value));
27
+ }
28
+ } finally { reader?.releaseLock(); }
29
+ let payload;
30
+ try { const text = Buffer.concat(chunks).toString('utf8'); payload = text ? JSON.parse(text) : {}; }
31
+ catch { throw Error('HTTP_BAD_JSON'); }
32
+ return { response, payload };
33
+ };
34
+ try { return await Promise.race([request(), deadline]); }
35
+ catch (error) { if (controller.signal.aborted && error.message !== 'HTTP_RESPONSE_TOO_LARGE') throw Error('REQUEST_TIMEOUT'); throw error; }
36
+ finally { clearTimeout(timer); }
37
+ }
package/index.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { privateDirectory, checkPrivateFile, withPrivateLock, atomicPrivateJson } from './privateState.mjs';
2
+ import { boundedJsonRequest } from './http.mjs';
1
3
  import { createHash, randomUUID } from 'node:crypto';
2
4
  import { existsSync } from 'node:fs';
3
5
  import { mkdir, readFile, writeFile, stat } from 'node:fs/promises';
@@ -5,7 +7,7 @@ import os from 'node:os';
5
7
  import path from 'node:path';
6
8
  import { ConvexHttpClient } from 'convex/browser';
7
9
  import { anyApi as api } from 'convex/server';
8
- export { installRuntimePackage, runtimePrincipal, writeRuntimeRecord } from './runtime.mjs';
10
+ export { verifyDirectory as verifyRuntimePackageDirectory, inspectRuntimeArchive, installRuntimePackage, runtimePrincipal, writeRuntimeRecord } from './runtime.mjs';
9
11
  export { reportRuntimeResult, retryRuntimeResults } from './runtimeOutbox.mjs';
10
12
  export { runRuntimeScript } from './runtimeScript.mjs';
11
13
  export { serveRuntimeScriptHost, retryRuntimeHostResults } from './runtimeHost.mjs';
@@ -13,7 +15,9 @@ export { createRuntimeAgentHost } from './runtimeAgentHost.mjs';
13
15
  export { createRuntimeAgentSession } from './runtimeAgent.mjs';
14
16
  export { serveRuntimeMcp } from './runtimeMcp.mjs';
15
17
 
16
- export const VERSION = '0.1.36';
18
+ export { safeOutput as redactCredentials } from './outputSafety.mjs';
19
+
20
+ export const VERSION = '0.1.38';
17
21
  export { probeRuntimeGateway, runtimeSetupTemplate } from './runtimeSetup.mjs';
18
22
 
19
23
  export const DEFAULTS = {
@@ -121,6 +125,8 @@ export async function readConfig() {
121
125
  if (!existsSync(CONFIG_PATH)) {
122
126
  return freshConfig();
123
127
  }
128
+ await privateDirectory(path.dirname(CONFIG_PATH));
129
+ await checkPrivateFile(CONFIG_PATH);
124
130
  const raw = await readFile(CONFIG_PATH, 'utf8');
125
131
  try {
126
132
  const parsed = JSON.parse(raw);
@@ -139,14 +145,21 @@ export async function readConfig() {
139
145
  profiles
140
146
  };
141
147
  } catch (error) {
142
- throw new XiasheError(`Invalid ${DEFAULT_PRODUCT_NAME} config JSON at ${CONFIG_PATH}`, { code: 'CONFIG_INVALID_JSON', details: error });
148
+ throw new XiasheError(`Invalid ${DEFAULT_PRODUCT_NAME} config JSON at ${CONFIG_PATH}`, { code: 'CONFIG_INVALID_JSON' });
143
149
  }
144
150
  }
145
151
 
152
+ async function mutateConfig(operation) {
153
+ await privateDirectory(path.dirname(CONFIG_PATH));
154
+ return withPrivateLock(CONFIG_PATH, async () => {
155
+ const config = await readConfig();
156
+ await operation(config);
157
+ await atomicPrivateJson(CONFIG_PATH, config);
158
+ return config;
159
+ });
160
+ }
146
161
  export async function writeConfig(config) {
147
- await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
148
- await writeFile(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
149
- return config;
162
+ return mutateConfig(current => { for (const key of Object.keys(current)) delete current[key]; Object.assign(current, config); });
150
163
  }
151
164
 
152
165
  export async function getProfile(name) {
@@ -162,28 +175,40 @@ export async function getProfile(name) {
162
175
  };
163
176
  }
164
177
 
165
- export async function updateProfile(name, patch) {
166
- const config = await readConfig();
167
- const profileName = name || config.profile || DEFAULT_PROFILE;
168
- config.profiles = config.profiles || {};
169
- config.profiles[profileName] = {
170
- ...(config.profiles[profileName] || {}),
171
- ...patch,
172
- name: profileName
178
+ const endpointKeys = ['convexUrl', 'actionsUrl', 'mcpUrl', 'agentClaimUrl', 'relayUrl'];
179
+ function environmentBinding(profile) {
180
+ const actions = normalizeBaseUrl(profile.actionsUrl);
181
+ const endpoints = {
182
+ ...profile,
183
+ mcpUrl: profile.mcpUrl || (actions ? `${actions}/mcp/xiashe` : ''),
184
+ agentClaimUrl: profile.agentClaimUrl || (actions ? `${actions}/agent-gateway/claim` : ''),
173
185
  };
174
- return writeConfig(config);
186
+ return Object.fromEntries(endpointKeys.map(key => [key, normalizeBaseUrl(endpoints[key])]));
175
187
  }
176
188
 
189
+ function sameEnvironment(left, right) {
190
+ const a = environmentBinding(left || {}), b = environmentBinding(right || {});
191
+ return endpointKeys.every(key => a[key] === b[key]);
192
+ }
193
+ export async function updateProfile(name, patch) {
194
+ return mutateConfig(config => {
195
+ const profileName = name || config.profile || DEFAULT_PROFILE;
196
+ const previous = { ...freshConfig().profiles[DEFAULT_PROFILE], ...(config.profiles[profileName] || {}) };
197
+ const next = { ...previous, ...patch, name: profileName };
198
+ if (!sameEnvironment(previous, next)) { delete next.session; delete next.agent; }
199
+ config.profiles[profileName] = next;
200
+ });
201
+ }
177
202
  export async function useProfile(name) {
178
- const config = await readConfig();
179
- config.profile = name || DEFAULT_PROFILE;
180
- config.profiles = config.profiles || {};
181
- config.profiles[config.profile] = config.profiles[config.profile] || { name: config.profile };
182
- return writeConfig(config);
203
+ return mutateConfig(config => {
204
+ config.profile = name || DEFAULT_PROFILE;
205
+ config.profiles[config.profile] ||= { name: config.profile };
206
+ });
183
207
  }
184
208
 
185
209
  export async function useEnvironment(envName, profileName) {
186
210
  const normalized = String(envName || '').toLowerCase();
211
+ if (!['cn', 'global', 'intl'].includes(normalized)) throw Error('ENVIRONMENT_INVALID');
187
212
  const defaults = normalized === 'global' || normalized === 'intl' ? DEFAULTS.global : DEFAULTS.cn;
188
213
  await updateProfile(profileName, {
189
214
  market: defaults.market,
@@ -200,10 +225,11 @@ export async function useEnvironment(envName, profileName) {
200
225
  function envProfile(profile) {
201
226
  const defaults = DEFAULTS[DEFAULT_ENV];
202
227
  const actionsUrl = normalizeBaseUrl(process.env.XIASHE_ACTIONS_URL || process.env.AGENTPIE_ACTIONS_URL || profile.actionsUrl);
203
- const mcpUrl = process.env.XIASHE_MCP_URL || process.env.AGENTPIE_MCP_URL || (actionsUrl ? `${actionsUrl}/mcp/xiashe` : profile.mcpUrl);
228
+ const changedActions = process.env.XIASHE_ACTIONS_URL || process.env.AGENTPIE_ACTIONS_URL;
229
+ const mcpUrl = process.env.XIASHE_MCP_URL || process.env.AGENTPIE_MCP_URL || (!changedActions && profile.mcpUrl) || (actionsUrl ? `${actionsUrl}/mcp/xiashe` : undefined);
204
230
  const agentClaimUrl =
205
- process.env.XIASHE_AGENT_CLAIM_URL || process.env.AGENTPIE_AGENT_CLAIM_URL || (actionsUrl ? `${actionsUrl}/agent-gateway/claim` : profile.agentClaimUrl);
206
- return {
231
+ process.env.XIASHE_AGENT_CLAIM_URL || process.env.AGENTPIE_AGENT_CLAIM_URL || (!changedActions && profile.agentClaimUrl) || (actionsUrl ? `${actionsUrl}/agent-gateway/claim` : undefined);
232
+ const resolved = {
207
233
  ...profile,
208
234
  convexUrl: process.env.XIASHE_CONVEX_URL || process.env.AGENTPIE_CONVEX_URL || profile.convexUrl,
209
235
  actionsUrl,
@@ -212,24 +238,33 @@ function envProfile(profile) {
212
238
  relayUrl: process.env.XIASHE_RELAY_URL || process.env.AGENTPIE_RELAY_URL || profile.relayUrl,
213
239
  market: process.env.XIASHE_MARKET || process.env.AGENTPIE_MARKET || profile.market || defaults.market,
214
240
  locale: process.env.XIASHE_LOCALE || process.env.AGENTPIE_LOCALE || profile.locale || defaults.locale,
215
- session: {
216
- ...(profile.session || {}),
217
- ...(process.env.XIASHE_USER_ID || process.env.XIASHE_SESSION_TOKEN
218
- ? {
219
- userId: process.env.XIASHE_USER_ID || profile.session?.userId,
220
- sessionToken: process.env.XIASHE_SESSION_TOKEN || profile.session?.sessionToken
221
- }
222
- : {})
223
- },
224
- agent: {
225
- ...(profile.agent || {}),
226
- ...(process.env.XIASHE_RUNTIME_TOKEN
227
- ? {
228
- runtimeToken: process.env.XIASHE_RUNTIME_TOKEN
229
- }
230
- : {})
231
- }
241
+ session: { ...(profile.session || {}) },
242
+ agent: { ...(profile.agent || {}) }
232
243
  };
244
+ for (const key of ['session', 'agent']) {
245
+ const binding = profile[key]?.environmentBinding || environmentBinding(profile);
246
+ if (!sameEnvironment(binding, resolved)) resolved[key] = {};
247
+ else if (Object.keys(resolved[key]).length) resolved[key].environmentBinding = binding;
248
+ }
249
+ const userId = process.env.XIASHE_USER_ID || process.env.AGENTPIE_USER_ID;
250
+ const sessionToken = process.env.XIASHE_SESSION_TOKEN || process.env.AGENTPIE_SESSION_TOKEN;
251
+ const runtimeToken = process.env.XIASHE_RUNTIME_TOKEN || process.env.AGENTPIE_RUNTIME_TOKEN;
252
+ let injectedBinding;
253
+ if (userId || sessionToken || runtimeToken) {
254
+ const issuer = process.env.XIASHE_AUTH_ENVIRONMENT || process.env.AGENTPIE_AUTH_ENVIRONMENT;
255
+ const rawBinding = process.env.XIASHE_AUTH_BINDING || process.env.AGENTPIE_AUTH_BINDING;
256
+ try { injectedBinding = rawBinding ? JSON.parse(rawBinding) : DEFAULTS[issuer === 'intl' ? 'global' : issuer]; }
257
+ catch { throw new XiasheError('Invalid environment credential binding.', { code: 'AUTH_ENVIRONMENT_MISMATCH' }); }
258
+ if (!injectedBinding || !sameEnvironment(injectedBinding, resolved))
259
+ throw new XiasheError('Environment credentials require a matching issuer binding. Set AUTH_ENVIRONMENT for this brand or authenticate again.', { code: 'AUTH_ENVIRONMENT_MISMATCH' });
260
+ }
261
+ if (userId || sessionToken) {
262
+ if (!userId || !sessionToken) throw new XiasheError('Environment authentication requires both user ID and session token.', { code: 'AUTH_ENV_INCOMPLETE' });
263
+ resolved.session = { userId, sessionToken, environmentBinding: environmentBinding(injectedBinding) };
264
+ }
265
+ if (runtimeToken) resolved.agent = { runtimeToken, environmentBinding: environmentBinding(injectedBinding) };
266
+
267
+ return resolved;
233
268
  }
234
269
 
235
270
  function normalizeConvexError(error) {
@@ -264,6 +299,9 @@ export async function loadProfile(options = {}) {
264
299
  profileName,
265
300
  profile: envProfile({
266
301
  ...profile,
302
+ // A legacy saved token has no verifiable issuer. Re-authenticate once.
303
+ session: profile.session?.environmentBinding ? profile.session : {},
304
+ agent: profile.agent?.environmentBinding ? profile.agent : {},
267
305
  ...cleanOptions
268
306
  })
269
307
  };
@@ -285,35 +323,13 @@ async function withTimeout(promiseFactory, timeoutMs = HTTP_TIMEOUT_MS) {
285
323
  }
286
324
 
287
325
  export async function postJson(url, body, options = {}) {
288
- const timeoutMs = options.timeoutMs || HTTP_TIMEOUT_MS;
289
- const headers = options.headers || {};
290
- const response = await withTimeout(
291
- (signal) =>
292
- fetch(url, {
293
- method: 'POST',
294
- headers: {
295
- 'content-type': 'application/json',
296
- ...headers
297
- },
298
- body: JSON.stringify(body ?? {}),
299
- signal
300
- }),
301
- timeoutMs
302
- );
303
- const text = await response.text();
304
- let payload = {};
305
- try {
306
- payload = text ? JSON.parse(text) : {};
307
- } catch {
308
- throw new XiasheError(`Bad JSON response from ${url}`, { code: 'HTTP_BAD_JSON', status: response.status });
309
- }
310
- if (!response.ok) {
311
- throw new XiasheError(payload.error || payload.message || `HTTP ${response.status}`, {
312
- code: payload.error || `HTTP_${response.status}`,
313
- status: response.status,
314
- details: payload
315
- });
316
- }
326
+ let result;
327
+ try { result = await boundedJsonRequest(url, body, options); }
328
+ catch (error) { throw new XiasheError(error.message, { code: error.message }); }
329
+ const { response, payload } = result;
330
+ if (!response.ok) throw new XiasheError(payload?.error || payload?.message || `HTTP ${response.status}`, {
331
+ code: payload?.error || `HTTP_${response.status}`, status: response.status
332
+ });
317
333
  return payload;
318
334
  }
319
335
 
@@ -381,6 +397,8 @@ export class XiasheClient {
381
397
  }
382
398
 
383
399
  authArgs(extra = {}) {
400
+ if (this.profile.session?.environmentBinding && !sameEnvironment(this.profile.session.environmentBinding, this.profile))
401
+ throw new XiasheError('Environment changed. Sign in again.', { code: 'AUTH_ENVIRONMENT_MISMATCH' });
384
402
  const userId = this.profile.session?.userId;
385
403
  const sessionToken = this.profile.session?.sessionToken;
386
404
  if (!userId || !sessionToken) {
@@ -455,10 +473,11 @@ export class XiasheClient {
455
473
  assertNumbersEnvironment() {
456
474
  const pairs = new Map([
457
475
  ['https://actions.xiashe.chat', 'https://api.xiashe.chat'],
458
- ['https://actions.projectphenix.site', 'https://api.projectphenix.site']
476
+ ['https://actions.projectphenix.site', 'https://api.projectphenix.site'],
477
+ ['https://agentpie.app', DEFAULTS.global.convexUrl]
459
478
  ]);
460
479
  const actions = normalizeBaseUrl(this.profile.actionsUrl);
461
- if (this.profile.market !== 'cn' || !pairs.has(actions) || pairs.get(actions) !== normalizeBaseUrl(this.profile.convexUrl)) {
480
+ if (this.profile.market !== (actions === 'https://agentpie.app' ? 'intl' : 'cn') || !pairs.has(actions) || pairs.get(actions) !== normalizeBaseUrl(this.profile.convexUrl)) {
462
481
  throw new XiasheError('Skill numbers require matching official XiaShe backend and actions URLs.', { code: 'NUMBER_ENVIRONMENT_INVALID' });
463
482
  }
464
483
  }
@@ -467,6 +486,10 @@ export class XiasheClient {
467
486
  this.assertNumbersEnvironment();
468
487
  return this.query(api.skillNumbers.resolve, { number: String(number) });
469
488
  }
489
+ async skillNumberAnalytics(number) {
490
+ this.assertNumbersEnvironment();
491
+ return this.query(api.skillNumbers.analytics, this.authArgs({ number: String(number) }));
492
+ }
470
493
  async acquireSkillNumber(number) {
471
494
  this.assertNumbersEnvironment();
472
495
  return this.mutation(api.skillNumbers.acquire, this.authArgs({ number: String(number) }));
@@ -1477,6 +1500,9 @@ export class XiasheClient {
1477
1500
 
1478
1501
  async agentRest(pathname, body = {}, options = {}) {
1479
1502
  const baseUrl = normalizeBaseUrl(options.restBaseUrl || this.profile.agent?.restBaseUrl || this.profile.actionsUrl);
1503
+ if (!options.runtimeToken && (new URL(baseUrl).origin !== new URL(this.profile.actionsUrl).origin ||
1504
+ (this.profile.agent?.environmentBinding && !sameEnvironment(this.profile.agent.environmentBinding, this.profile))))
1505
+ throw new XiasheError('Environment changed. Reconnect the Agent.', { code: 'AUTH_ENVIRONMENT_MISMATCH' });
1480
1506
  const runtimeToken = options.runtimeToken || this.profile.agent?.runtimeToken;
1481
1507
  if (!runtimeToken) {
1482
1508
  throw new XiasheError('Missing Agent runtime token. Run `xiashe agent connect --token <token>` first.', {
@@ -1554,6 +1580,10 @@ export class XiasheClient {
1554
1580
  }
1555
1581
 
1556
1582
  async mcp(method, params = {}, options = {}) {
1583
+ if (!options.runtimeToken && this.profile.agent?.runtimeToken &&
1584
+ (normalizeBaseUrl(options.mcpUrl || this.profile.mcpUrl) !== normalizeBaseUrl(this.profile.mcpUrl) ||
1585
+ !sameEnvironment(this.profile.agent.environmentBinding || this.profile, this.profile)))
1586
+ throw new XiasheError('Environment changed. Reconnect the Agent.', { code: 'AUTH_ENVIRONMENT_MISMATCH' });
1557
1587
  return mcpCall(
1558
1588
  options.mcpUrl || this.profile.mcpUrl,
1559
1589
  options.runtimeToken || this.profile.agent?.runtimeToken || '',
@@ -1573,7 +1603,7 @@ export class XiasheClient {
1573
1603
  }
1574
1604
  }
1575
1605
 
1576
- export async function saveLoginSession(profileName, authResult) {
1606
+ export async function saveLoginSession(profileName, authResult, authenticatedProfile) {
1577
1607
  const session = {
1578
1608
  userId: authResult.userId,
1579
1609
  email: authResult.email,
@@ -1582,11 +1612,13 @@ export async function saveLoginSession(profileName, authResult) {
1582
1612
  sessionExpiresAt: authResult.sessionExpiresAt,
1583
1613
  defaultChatId: authResult.defaultChatId || null
1584
1614
  };
1615
+ const profile = authenticatedProfile || (await loadProfile({ profile: profileName })).profile;
1616
+ session.environmentBinding = environmentBinding(profile);
1585
1617
  await updateProfile(profileName, { session });
1586
1618
  return session;
1587
1619
  }
1588
1620
 
1589
- export async function saveAgentSession(profileName, claimResult) {
1621
+ export async function saveAgentSession(profileName, claimResult, authenticatedProfile) {
1590
1622
  const agent = {
1591
1623
  runtimeToken: claimResult.runtimeToken,
1592
1624
  agentConnectionId: claimResult.agentConnectionId,
@@ -1598,6 +1630,10 @@ export async function saveAgentSession(profileName, claimResult) {
1598
1630
  restBaseUrl: claimResult.restBaseUrl,
1599
1631
  expiresAt: claimResult.expiresAt
1600
1632
  };
1633
+ const profile = authenticatedProfile || (await loadProfile({ profile: profileName })).profile;
1634
+ agent.environmentBinding = environmentBinding(profile);
1635
+ if (agent.mcpUrl && new URL(agent.mcpUrl).origin !== new URL(profile.mcpUrl).origin) throw Error('AGENT_ENVIRONMENT_MISMATCH');
1636
+ if (agent.restBaseUrl && new URL(agent.restBaseUrl).origin !== new URL(profile.actionsUrl).origin) throw Error('AGENT_ENVIRONMENT_MISMATCH');
1601
1637
  await updateProfile(profileName, { agent });
1602
1638
  return agent;
1603
1639
  }
@@ -0,0 +1,21 @@
1
+ // Output-only filtering. Never transform source files or generated Skill content.
2
+ const secrets = new Set();
3
+ const credentialKey = key => /(?:token|secret|password|authorization|cookie|apikey|uploadurl|downloadurl)$/i.test(key.replace(/[-_]/g, ''));
4
+ export function rememberSecrets(value) {
5
+ if (!value || typeof value !== 'object') return value;
6
+ for (const [key, child] of Object.entries(value)) {
7
+ if (credentialKey(key) && typeof child === 'string' && child) secrets.add(child);
8
+ else if (child && typeof child === 'object') rememberSecrets(child);
9
+ }
10
+ return value;
11
+ }
12
+ export function safeOutput(value) {
13
+ rememberSecrets(value);
14
+ if (typeof value === 'string') {
15
+ for (const secret of [...secrets].sort((a, b) => b.length - a.length)) value = value.split(secret).join('[REDACTED]');
16
+ return value;
17
+ }
18
+ if (Array.isArray(value)) return value.map(safeOutput);
19
+ if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, credentialKey(key) && child ? '[REDACTED]' : safeOutput(child)]));
20
+ return value;
21
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiashe/sdk",
3
- "version": "0.1.36",
3
+ "version": "0.1.38",
4
4
  "type": "module",
5
5
  "main": "index.mjs",
6
6
  "exports": {
@@ -0,0 +1,61 @@
1
+ import { constants } from 'node:fs';
2
+ import { chmod, lstat, mkdir, open, realpath, rename, rm } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+
6
+ const missing = error => { if (error.code !== 'ENOENT') throw error; return null; };
7
+ export async function privateDirectory(directory) {
8
+ for (let parent = path.resolve(directory); parent !== path.dirname(parent); parent = path.dirname(parent)) {
9
+ const info = await lstat(parent).catch(missing);
10
+ // macOS exposes its root-owned temporary filesystem through /var and /tmp.
11
+ const systemAlias = process.platform === 'darwin' && ['/var', '/tmp'].includes(parent) && info?.uid === 0 &&
12
+ info.isSymbolicLink() && await realpath(parent) === `/private${parent}`;
13
+ if (info && !systemAlias && (!info.isDirectory() || info.isSymbolicLink())) throw Error('PRIVATE_STATE_UNSAFE_DIRECTORY');
14
+ }
15
+ await mkdir(directory, { recursive: true, mode: 0o700 });
16
+ const info = await lstat(directory);
17
+ if (!info.isDirectory() || info.isSymbolicLink()) throw Error('PRIVATE_STATE_UNSAFE_DIRECTORY');
18
+ await chmod(directory, 0o700);
19
+ }
20
+ export async function checkPrivateFile(file) {
21
+ const info = await lstat(file).catch(missing);
22
+ if (info && (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1)) throw Error('PRIVATE_STATE_UNSAFE_FILE');
23
+ if (info) await chmod(file, 0o600);
24
+ return info;
25
+ }
26
+ // Never steal a lock: a crashed process requires explicit local reconciliation.
27
+ export async function withPrivateLock(file, operation, { timeoutMs = 3000 } = {}) {
28
+ const lock = `${file}.lock`;
29
+ const deadline = Date.now() + timeoutMs;
30
+ let handle;
31
+ while (!handle) {
32
+ try { handle = await open(lock, 'wx', 0o600); }
33
+ catch (error) {
34
+ if (error.code !== 'EEXIST') throw error;
35
+ if (Date.now() >= deadline) throw Error('PRIVATE_STATE_BUSY: another process or an interrupted operation owns the lock');
36
+ await new Promise(resolve => setTimeout(resolve, 25));
37
+ }
38
+ }
39
+ try { return await operation(); }
40
+ finally { await handle.close(); await rm(lock); }
41
+ }
42
+ // Caller holds the lock around the complete read/modify/write transaction.
43
+ export async function atomicPrivateJson(file, value) {
44
+ const before = await checkPrivateFile(file);
45
+ const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${randomUUID()}.tmp`);
46
+ let handle;
47
+ try {
48
+ handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
49
+ await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`);
50
+ await handle.sync();
51
+ await handle.close(); handle = null;
52
+ const after = await checkPrivateFile(file);
53
+ if (Boolean(before) !== Boolean(after) || (before && (before.ino !== after.ino || before.dev !== after.dev))) throw Error('PRIVATE_STATE_CHANGED');
54
+ await rename(temporary, file);
55
+ // Atomic replacement is complete. Persist directory metadata where supported.
56
+ if (process.platform !== 'win32') {
57
+ const directory = await open(path.dirname(file), 'r');
58
+ try { await directory.sync(); } finally { await directory.close(); }
59
+ }
60
+ } finally { if (handle) await handle.close(); await rm(temporary, { force: true }); }
61
+ }
package/runtime.mjs CHANGED
@@ -72,7 +72,7 @@ export function runtimePrincipal(client) {
72
72
  return digest(`${new URL(client.profile.convexUrl).origin}\0${userId}`);
73
73
  }
74
74
 
75
- async function verifyDirectory(directory, files) {
75
+ export async function verifyDirectory(directory, files) {
76
76
  const expected = new Map(files.map(file => [file.path, file]));
77
77
  let count = 0;
78
78
  const walk = async (folder, prefix = '') => {