@moda-ai/cli 1.9.0 → 1.9.6

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/auth.js CHANGED
@@ -312,12 +312,44 @@ export async function authFetch(path, options = {}, authOptions = {}) {
312
312
  if (!token) {
313
313
  throw new Error('Not authenticated. Run "moda init" to log in.');
314
314
  }
315
- const url = `${resolveModaBaseUrl()}${path}`;
316
315
  const headers = new Headers(options.headers);
317
316
  headers.set('Authorization', `Bearer ${token}`);
318
317
  if (!headers.has('Content-Type') && options.body) {
319
318
  headers.set('Content-Type', 'application/json');
320
319
  }
321
- return fetch(url, { ...options, headers });
320
+ const origin = new URL(resolveModaBaseUrl()).origin;
321
+ let url = `${resolveModaBaseUrl()}${path}`;
322
+ // Follow redirects manually so EVERY hop is origin-checked, not just the
323
+ // first. Native `redirect: 'follow'` would silently strip the Authorization
324
+ // header on a cross-host hop (per the Fetch spec) — the exact failure that
325
+ // broke moda init (the legacy www → apex 308). Same-host hops keep the header
326
+ // and are safe to follow; any hop that leaves the Moda origin fails fast with
327
+ // an actionable error instead of surfacing as a confusing 401.
328
+ const MAX_REDIRECTS = 5;
329
+ for (let hop = 0;; hop++) {
330
+ const response = await fetch(url, { ...options, headers, redirect: 'manual' });
331
+ if (response.status < 300 || response.status >= 400) {
332
+ return response;
333
+ }
334
+ const location = response.headers.get('location');
335
+ const target = location ? safeResolveUrl(location, url) : null;
336
+ if (!target || target.origin !== origin) {
337
+ throw new Error(`Moda API redirected (${response.status}) to ${location ?? '(unknown)'}. ` +
338
+ 'Cross-host redirects strip Authorization and break moda init. ' +
339
+ 'Use MODA_BASE_URL=https://moda.dev (not www.moda.dev).');
340
+ }
341
+ if (hop >= MAX_REDIRECTS) {
342
+ throw new Error(`Moda API exceeded ${MAX_REDIRECTS} same-host redirects for ${path}.`);
343
+ }
344
+ url = target.toString();
345
+ }
346
+ }
347
+ function safeResolveUrl(location, base) {
348
+ try {
349
+ return new URL(location, base);
350
+ }
351
+ catch {
352
+ return null;
353
+ }
322
354
  }
323
355
  //# sourceMappingURL=auth.js.map
package/dist/config.d.ts CHANGED
@@ -27,7 +27,8 @@ export declare function saveCliConfig(update: CliConfig): void;
27
27
  export declare function resolveApiKey(options?: ProfileIOOptions): string | undefined;
28
28
  /**
29
29
  * Resolve the ingest worker base URL used by `moda workspace snapshot`.
30
- * Env override -> persisted config -> production default.
30
+ * Env override -> active profile -> legacy config -> production default.
31
+ * Loopback URLs are rejected unless the active profile is `local`.
31
32
  */
32
33
  export declare function resolveIngestUrl(options?: ProfileIOOptions): string;
33
34
  /**
package/dist/config.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import { readFileSync } from 'node:fs';
2
- import { DEFAULT_INGEST_URL, PROD_BASE_URL, getLegacyConfigPath, loadLegacyCliConfig, resolveApiKeyFromActiveProfile, resolveBaseUrl, resolveIngestUrlFromActiveProfile, resolveTenantIdFromActiveProfile, syncLegacyConfigToProfile, writeLegacyCliConfig, } from './profiles.js';
2
+ import { DEFAULT_INGEST_URL, PROD_BASE_URL, getLegacyConfigPath, loadLegacyCliConfig, resolveApiKeyFromActiveProfile, resolveBaseUrl, resolveIngestUrl as resolveProfileIngestUrl, resolveTenantIdFromActiveProfile, syncLegacyConfigToProfile, writeLegacyCliConfig, } from './profiles.js';
3
3
  const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
4
4
  export const VERSION = pkg.version;
5
- // Canonical host is www.moda.dev. The apex `moda.dev` 308-redirects to it, and
5
+ // Canonical host is moda.dev. The www host 308-redirects to the apex, and
6
6
  // cross-host redirects strip the Authorization header (fetch/undici), so using
7
- // the apex breaks `moda init`'s authenticated calls with a 401. Default to the
8
- // canonical host directly.
7
+ // www breaks `moda init`'s authenticated calls with a 401. Default to the
8
+ // canonical host directly; resolveBaseUrl() also rewrites legacy www URLs.
9
9
  export const MODA_BASE_URL = resolveBaseUrl();
10
10
  export function loadCliConfig(options = {}) {
11
11
  return loadLegacyCliConfig(options);
@@ -38,14 +38,11 @@ export function resolveApiKey(options = {}) {
38
38
  }
39
39
  /**
40
40
  * Resolve the ingest worker base URL used by `moda workspace snapshot`.
41
- * Env override -> persisted config -> production default.
41
+ * Env override -> active profile -> legacy config -> production default.
42
+ * Loopback URLs are rejected unless the active profile is `local`.
42
43
  */
43
44
  export function resolveIngestUrl(options = {}) {
44
- const env = options.env ?? process.env;
45
- return (env.MODA_INGEST_URL ||
46
- resolveIngestUrlFromActiveProfile(options) ||
47
- loadCliConfig(options).ingest_url ||
48
- DEFAULT_INGEST_URL);
45
+ return resolveProfileIngestUrl(options);
49
46
  }
50
47
  /**
51
48
  * Resolve the active tenant id: env override first, then the persisted
@@ -1,5 +1,5 @@
1
1
  export declare const PROFILE_CONFIG_VERSION = 1;
2
- export declare const PROD_BASE_URL = "https://www.moda.dev";
2
+ export declare const PROD_BASE_URL = "https://moda.dev";
3
3
  export declare const DEFAULT_INGEST_URL = "https://moda-ingest.modas.workers.dev";
4
4
  export declare const DEFAULT_PROFILE_NAME = "prod";
5
5
  export type ModaEnvironment = 'local' | 'dev' | 'staging' | 'prod';
@@ -71,6 +71,7 @@ export declare function getLegacyConfigPath(options?: ProfileIOOptions): string;
71
71
  export declare function loadLegacyCliConfig(options?: ProfileIOOptions): LegacyCliConfig;
72
72
  export declare function writeLegacyCliConfig(update: LegacyCliConfig, options?: ProfileIOOptions): LegacyCliConfig;
73
73
  export declare function createDefaultProfiles(options?: ProfileIOOptions): Record<string, ModaProfile>;
74
+ export declare function isLoopbackBaseUrl(url: string): boolean;
74
75
  export declare function assertValidProfileName(name: string): void;
75
76
  export declare function inspectProfileMigrationState(options?: ProfileIOOptions): ProfileMigrationState;
76
77
  export declare function loadProfileConfig(options?: ProfileIOOptions): ProfileConfig;
@@ -82,7 +83,14 @@ export declare function syncLegacyConfigToProfile(legacy: LegacyCliConfig, optio
82
83
  export declare function secretFilePathFromRef(reference: SecretReference, options?: ProfileIOOptions): string | undefined;
83
84
  export declare function resolveSecretReference(reference: SecretReference | undefined, options?: ProfileIOOptions): string | undefined;
84
85
  export declare function resolveApiKeyFromActiveProfile(options?: ProfileIOOptions): string | undefined;
86
+ /**
87
+ * Vercel serves moda.dev as canonical and 308-redirects www to the apex. Node
88
+ * fetch strips Authorization on cross-host redirects, which breaks moda init
89
+ * step 3 when the base URL still points at www.
90
+ */
91
+ export declare function normalizeModaBaseUrl(url: string): string;
85
92
  export declare function resolveBaseUrl(options?: ProfileIOOptions): string;
93
+ export declare function resolveIngestUrl(options?: ProfileIOOptions): string;
86
94
  export declare function resolveIngestUrlFromActiveProfile(options?: ProfileIOOptions): string | undefined;
87
95
  export declare function resolveTenantIdFromActiveProfile(options?: ProfileIOOptions): string | undefined;
88
96
  export declare function ensureSecretFilePermissions(path: string, repair?: boolean): SecretFilePermissionStatus;
package/dist/profiles.js CHANGED
@@ -2,7 +2,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync
2
2
  import { homedir } from 'node:os';
3
3
  import { dirname, isAbsolute, join, normalize, relative } from 'node:path';
4
4
  export const PROFILE_CONFIG_VERSION = 1;
5
- export const PROD_BASE_URL = 'https://www.moda.dev';
5
+ export const PROD_BASE_URL = 'https://moda.dev';
6
6
  export const DEFAULT_INGEST_URL = 'https://moda-ingest.modas.workers.dev';
7
7
  export const DEFAULT_PROFILE_NAME = 'prod';
8
8
  function getEnv(options = {}) {
@@ -93,6 +93,65 @@ export function createDefaultProfiles(options = {}) {
93
93
  prod: profileDefaults('prod', 'prod', now),
94
94
  };
95
95
  }
96
+ function isLoopbackHostname(hostname) {
97
+ const host = hostname.toLowerCase();
98
+ return (host === 'localhost' ||
99
+ host === '127.0.0.1' ||
100
+ host === '::1' ||
101
+ host === '[::1]');
102
+ }
103
+ export function isLoopbackBaseUrl(url) {
104
+ try {
105
+ return isLoopbackHostname(new URL(url).hostname);
106
+ }
107
+ catch {
108
+ return false;
109
+ }
110
+ }
111
+ function defaultBaseUrlForEnvironment(environment) {
112
+ if (environment === 'local')
113
+ return 'http://localhost:3000';
114
+ if (environment === 'staging')
115
+ return 'https://staging.moda.dev';
116
+ return PROD_BASE_URL;
117
+ }
118
+ function defaultIngestUrlForEnvironment(environment) {
119
+ if (environment === 'local')
120
+ return 'http://localhost:8787';
121
+ return DEFAULT_INGEST_URL;
122
+ }
123
+ function environmentAllowsLoopback(environment) {
124
+ return environment !== 'prod';
125
+ }
126
+ function sanitizeProfileBaseUrl(url, environment) {
127
+ const normalized = normalizeModaBaseUrl(url);
128
+ if (environmentAllowsLoopback(environment) || !isLoopbackBaseUrl(normalized)) {
129
+ return normalized;
130
+ }
131
+ return PROD_BASE_URL;
132
+ }
133
+ function sanitizeProfileIngestUrl(url, environment) {
134
+ if (!url)
135
+ return url;
136
+ if (environmentAllowsLoopback(environment) || !isLoopbackBaseUrl(url)) {
137
+ return url;
138
+ }
139
+ return DEFAULT_INGEST_URL;
140
+ }
141
+ function enforceNonLoopbackBaseUrl(url, environment) {
142
+ return sanitizeProfileBaseUrl(url, environment);
143
+ }
144
+ function enforceNonLoopbackIngestUrl(url, environment) {
145
+ return sanitizeProfileIngestUrl(url, environment) ?? defaultIngestUrlForEnvironment(environment);
146
+ }
147
+ function activeProfileEnvironment(options) {
148
+ try {
149
+ return getActiveProfile(options).environment;
150
+ }
151
+ catch {
152
+ return 'prod';
153
+ }
154
+ }
96
155
  function normalizeEnvironment(value) {
97
156
  if (value === 'local' ||
98
157
  value === 'dev' ||
@@ -131,8 +190,8 @@ function normalizeProfile(fallbackName, value, options) {
131
190
  const defaults = profileDefaults(name, normalizeEnvironment(value.environment), options.now ?? new Date());
132
191
  return {
133
192
  ...defaults,
134
- base_url: optionalString(value.base_url) ?? defaults.base_url,
135
- ingest_url: optionalString(value.ingest_url) ?? defaults.ingest_url,
193
+ base_url: sanitizeProfileBaseUrl(optionalString(value.base_url) ?? defaults.base_url, normalizeEnvironment(value.environment)),
194
+ ingest_url: sanitizeProfileIngestUrl(optionalString(value.ingest_url) ?? defaults.ingest_url, normalizeEnvironment(value.environment)),
136
195
  tenant_id: optionalString(value.tenant_id),
137
196
  workspace_id: optionalString(value.workspace_id),
138
197
  api_key_ref: normalizeSecretReference(value.api_key_ref),
@@ -170,9 +229,12 @@ function applyLegacyAwareness(config, options) {
170
229
  const migration = inspectProfileMigrationState(options);
171
230
  const prod = config.profiles.prod;
172
231
  if (prod && (legacy.api_key || legacy.ingest_url || legacy.tenant_id)) {
232
+ const migratedIngestUrl = legacy.ingest_url && !isLoopbackBaseUrl(legacy.ingest_url)
233
+ ? legacy.ingest_url
234
+ : prod.ingest_url;
173
235
  config.profiles.prod = {
174
236
  ...prod,
175
- ingest_url: legacy.ingest_url ?? prod.ingest_url,
237
+ ingest_url: migratedIngestUrl,
176
238
  tenant_id: legacy.tenant_id ?? prod.tenant_id,
177
239
  api_key_ref: legacy.api_key ? 'legacy:config.api_key' : prod.api_key_ref,
178
240
  auth_token_ref: migration.legacy_auth.has_access_token
@@ -343,17 +405,58 @@ export function resolveApiKeyFromActiveProfile(options = {}) {
343
405
  return undefined;
344
406
  }
345
407
  }
408
+ /**
409
+ * Vercel serves moda.dev as canonical and 308-redirects www to the apex. Node
410
+ * fetch strips Authorization on cross-host redirects, which breaks moda init
411
+ * step 3 when the base URL still points at www.
412
+ */
413
+ export function normalizeModaBaseUrl(url) {
414
+ try {
415
+ const parsed = new URL(url);
416
+ if (parsed.hostname === 'www.moda.dev') {
417
+ parsed.hostname = 'moda.dev';
418
+ return parsed.toString().replace(/\/$/, '');
419
+ }
420
+ }
421
+ catch {
422
+ // Not a valid URL; return unchanged and let the caller surface the error.
423
+ }
424
+ return url.replace(/\/$/, '');
425
+ }
346
426
  export function resolveBaseUrl(options = {}) {
427
+ const environment = activeProfileEnvironment(options);
347
428
  const envBase = optionalString(getEnv(options).MODA_BASE_URL);
348
- if (envBase)
349
- return envBase;
429
+ if (envBase) {
430
+ return enforceNonLoopbackBaseUrl(normalizeModaBaseUrl(envBase), environment);
431
+ }
350
432
  try {
351
- return getActiveProfile(options).base_url || PROD_BASE_URL;
433
+ return enforceNonLoopbackBaseUrl(normalizeModaBaseUrl(getActiveProfile(options).base_url || PROD_BASE_URL), environment);
352
434
  }
353
435
  catch {
354
436
  return PROD_BASE_URL;
355
437
  }
356
438
  }
439
+ export function resolveIngestUrl(options = {}) {
440
+ const environment = activeProfileEnvironment(options);
441
+ const envIngest = optionalString(getEnv(options).MODA_INGEST_URL);
442
+ if (envIngest) {
443
+ return enforceNonLoopbackIngestUrl(envIngest, environment);
444
+ }
445
+ try {
446
+ const profileIngest = getActiveProfile(options).ingest_url;
447
+ if (profileIngest) {
448
+ return enforceNonLoopbackIngestUrl(profileIngest, environment);
449
+ }
450
+ }
451
+ catch {
452
+ // Fall through to legacy/default below.
453
+ }
454
+ const legacyIngest = optionalString(loadLegacyCliConfig(options).ingest_url);
455
+ if (legacyIngest) {
456
+ return enforceNonLoopbackIngestUrl(legacyIngest, environment);
457
+ }
458
+ return defaultIngestUrlForEnvironment(environment);
459
+ }
357
460
  export function resolveIngestUrlFromActiveProfile(options = {}) {
358
461
  try {
359
462
  return getActiveProfile(options).ingest_url;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moda-ai/cli",
3
- "version": "1.9.0",
3
+ "version": "1.9.6",
4
4
  "description": "CLI for Moda - AI agent analytics and observability",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,8 +13,7 @@
13
13
  "!dist/__tests__",
14
14
  "hooks/**",
15
15
  "skills/**/SKILL.md",
16
- "README.md",
17
- ".env.example"
16
+ "README.md"
18
17
  ],
19
18
  "publishConfig": {
20
19
  "access": "public"
package/.env.example DELETED
@@ -1,8 +0,0 @@
1
- # Moda API Key (required)
2
- # Get this from https://moda.dev/settings
3
- MODA_API_KEY=moda_sk_...
4
-
5
- # Moda Backend URL (optional)
6
- # Defaults to production: https://moda.dev
7
- # For local development, override with:
8
- # MODA_BASE_URL=http://localhost:3001