ninepm 0.57.0 → 0.58.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/dist/index.d.ts CHANGED
@@ -116,7 +116,7 @@ export interface CreatedServiceToken {
116
116
  clientSecret: string;
117
117
  }
118
118
  export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
119
- export declare const CLI_VERSION = "0.57.0";
119
+ export declare const CLI_VERSION = "0.58.0";
120
120
  type CredentialBackend = "macos-keychain" | "windows-dpapi" | "linux-secret-service" | "file-plaintext" | "none";
121
121
  export interface StoredCredentialEntry {
122
122
  token: string;
@@ -220,7 +220,7 @@ export declare function measureWorkerEntry(source: Uint8Array): {
220
220
  rawBytes: number;
221
221
  compressedBytes: number;
222
222
  };
223
- export declare function measureWorkerModuleFiles(root: string, modulePaths: readonly string[]): Promise<{
223
+ export declare function measureWorkerModuleFiles(root: string, modulePaths: readonly string[], onSource?: (source: string) => void): Promise<{
224
224
  rawBytes: number;
225
225
  compressedBytes: number;
226
226
  }>;
package/dist/index.js CHANGED
@@ -11,10 +11,11 @@ import { once } from "node:events";
11
11
  import { createGzip, gzipSync } from "node:zlib";
12
12
  import { Agent, fetch as undiciFetch } from "undici";
13
13
  import { MAX_DEPLOY_DESCRIPTION_LENGTH, normalizeDeployDescription } from "./deploy-description.js";
14
+ import { managedHostnamesIn, siblingCallWarning, siblingsOf } from "./sibling-hostnames.js";
14
15
  import { MAX_STATIC_ASSET_FILE_BYTES, MAX_WORKER_ENTRY_COMPRESSED_BYTES, MAX_WORKER_SCRIPT_COMPRESSED_BYTES, WORKER_ENTRY_COMPRESSED_WARN_BYTES, assetFileTooLargeMessage, formatMebibytes, formatMegabytes, oversizedAssets, workerBundleNearLimitMessage, workerBundleTooLargeMessage, workerEntryNearLimitMessage, workerEntryTooLargeMessage, } from "./deploy-limits.js";
15
16
  import { buildFileManifest, readManifestFile } from "./manifest.js";
16
17
  import { API_KEY_HELP, API_URL_HELP, CLI_CLIENT_ID, DEFAULT_API_URL, reportCliError } from "./cli-config.js";
17
- export const CLI_VERSION = "0.57.0";
18
+ export const CLI_VERSION = "0.58.0";
18
19
  const PUBLIC_DASHBOARD_URL = "https://9pm.ai/dashboard";
19
20
  const TOKEN_SERVICE = "9pm";
20
21
  const TOKEN_ACCOUNT = "default";
@@ -491,13 +492,22 @@ async function runDeploy(initialOptions, slugHolder) {
491
492
  console.warn(`Warning: ${workerEntryNearLimitMessage(serverEntry, rawBytes, compressedBytes)}`);
492
493
  }
493
494
  }
495
+ // Managed app hostnames written into this app's server code (#1332), filled from the
496
+ // size passes above and below - the only passes that hold every server module's text -
497
+ // and read once the project id exists. A bundle is streamed through gzip precisely so it
498
+ // is never all in memory at once, so the matches are what gets carried, not the sources.
499
+ const managedHostnames = new Set();
494
500
  // Bundles carry the same budget, applied to all their server modules together (#660).
495
501
  // Without this the `--bundle` path — the one the hosted skill's size guidance is about —
496
502
  // was the only worker shape with no local size check at all, so an over-budget framework
497
503
  // build failed at publish instead, after the project and slug existed.
498
504
  if (bundle) {
499
505
  const modulePaths = bundleModulePaths(bundle);
500
- const { rawBytes, compressedBytes } = await measureWorkerModuleFiles(contentRoot, modulePaths);
506
+ const { rawBytes, compressedBytes } = await measureWorkerModuleFiles(contentRoot, modulePaths, (source) => {
507
+ for (const hostname of managedHostnamesIn(source)) {
508
+ managedHostnames.add(hostname);
509
+ }
510
+ });
501
511
  if (compressedBytes > MAX_WORKER_ENTRY_COMPRESSED_BYTES) {
502
512
  throw new Error(workerBundleTooLargeMessage(modulePaths.length, rawBytes, compressedBytes));
503
513
  }
@@ -509,6 +519,26 @@ async function runDeploy(initialOptions, slugHolder) {
509
519
  const { project, created: projectCreated } = await createProject(fetchFn, apiUrl, options.token, projectInput);
510
520
  slugHolder.slug = project.slug;
511
521
  slugHolder.created = projectCreated;
522
+ // #1332: a worker-runtime app cannot reach a sibling over its public hostname - the
523
+ // request never leaves the platform's network - and nothing else would tell the author,
524
+ // because the callee answers 200 to everyone outside. Both worker shapes are scanned
525
+ // off bytes the size preflight above already read: the entry from the copy it holds for
526
+ // the upload, a bundle from the stream it was measured on.
527
+ //
528
+ // After the project resolves, not beside those size checks, because the exclusion that
529
+ // makes this warning readable needs the project id: an app's own hostname is the
530
+ // reference the #1237 audit found most of, so without it this fires on nearly every
531
+ // worker deploy. Unlike those checks it refuses nothing, so it has no reason to run
532
+ // before the slug is claimed.
533
+ if (serverEntryBytes) {
534
+ for (const hostname of managedHostnamesIn(new TextDecoder().decode(serverEntryBytes))) {
535
+ managedHostnames.add(hostname);
536
+ }
537
+ }
538
+ const siblings = siblingsOf(managedHostnames, { ownProjectId: project.id });
539
+ if (siblings.length > 0) {
540
+ console.warn(`Warning: ${siblingCallWarning(siblings)}`);
541
+ }
512
542
  const database = options.withDatabase
513
543
  ? await createDatabase(fetchFn, apiUrl, options.token, project.id, {
514
544
  name: options.databaseName ?? `${project.slug}-db`,
@@ -593,7 +623,10 @@ export function measureWorkerEntry(source) {
593
623
  // It deliberately does not stop early once the limit is passed. The messages report exact
594
624
  // byte counts — that precision is what keeps them unambiguous at the boundary — and a floor
595
625
  // like "at least N bytes" would give that up to save a second on an already-failing deploy.
596
- export async function measureWorkerModuleFiles(root, modulePaths) {
626
+ export async function measureWorkerModuleFiles(root, modulePaths,
627
+ // Handed each module's text as the pass streams it. Optional, and skipped entirely when
628
+ // absent, so `--check` still never decodes a bundle it only needs the size of.
629
+ onSource) {
597
630
  const gzip = createGzip();
598
631
  let rawBytes = 0;
599
632
  let compressedBytes = 0;
@@ -614,6 +647,7 @@ export async function measureWorkerModuleFiles(root, modulePaths) {
614
647
  for (const modulePath of modulePaths) {
615
648
  const bytes = await readManifestFile(root, modulePath);
616
649
  rawBytes += bytes.byteLength;
650
+ onSource?.(new TextDecoder().decode(bytes));
617
651
  if (!gzip.write(bytes)) {
618
652
  // Respect backpressure so a large bundle cannot balloon the stream's internal queue.
619
653
  await once(gzip, "drain");
@@ -718,6 +752,12 @@ export async function checkDeployDirectory(options) {
718
752
  const entrySource = await validateWorkerEntrySource(source, serverEntry);
719
753
  warnings.push(...entrySource.warnings);
720
754
  errors.push(...entrySource.errors);
755
+ // The sibling-hostname warning (#1332) deliberately does NOT run here, though this
756
+ // has the source in hand and #645 otherwise wants the two paths agreeing. It needs
757
+ // the project id to tell a sibling's hostname from the app's own, and `--check` runs
758
+ // against a directory, before any project exists - so here it could only report
759
+ // every app's reference to ITSELF, which is the false positive that would teach
760
+ // people to stop reading it.
721
761
  }
722
762
  }
723
763
  // Same server-code budget the real deploy applies to a bundle (#660), measured the same
@@ -0,0 +1,34 @@
1
+ export declare const DEFAULT_APP_HOST_BASE = "9pm.ai";
2
+ /**
3
+ * Every managed app hostname appearing in `source`, the deploying app's own included.
4
+ *
5
+ * Matched on the HOST portion only - a bare hostname, or one inside a URL - so a path or
6
+ * query string that happens to contain the base domain does not register.
7
+ *
8
+ * Separate from the own-app filter below because the deploy path learns the two at
9
+ * different moments: a `--bundle` build is scanned module by module as the size pass
10
+ * streams it, which happens before the project (and so the id that identifies the app's
11
+ * own hostname) exists. Collecting the handful of matches costs nothing to carry; holding
12
+ * the sources until the project resolves would mean holding the whole bundle.
13
+ */
14
+ export declare function managedHostnamesIn(source: string, options?: {
15
+ appHostBase?: string;
16
+ }): string[];
17
+ /**
18
+ * Of `hostnames`, the ones belonging to some OTHER app.
19
+ *
20
+ * `ownProjectId` is the id of the project being deployed; without it every reference an
21
+ * app makes to ITSELF is reported, which the #1237 audit found to be most of the
22
+ * references on the estate - so the deploy path must not call this before the project
23
+ * resolves.
24
+ */
25
+ export declare function siblingsOf(hostnames: Iterable<string>, options?: {
26
+ appHostBase?: string;
27
+ ownProjectId?: string | null;
28
+ }): string[];
29
+ /** `managedHostnamesIn` and `siblingsOf` in one, for a caller holding the whole source. */
30
+ export declare function siblingHostnamesIn(source: string, options?: {
31
+ appHostBase?: string;
32
+ ownProjectId?: string | null;
33
+ }): string[];
34
+ export declare function siblingCallWarning(hostnames: string[]): string;
@@ -0,0 +1,128 @@
1
+ // AUTO-GENERATED by scripts/generate-cli-package.mjs from the in-repo CLI source -- do not edit.
2
+ // Edit src/cli or src/shared instead; this is regenerated on typecheck/test/build/pack.
3
+ // #1332: a Worker-runtime app must not call a sibling app by its public hostname.
4
+ //
5
+ // A same-zone Worker `fetch()` bypasses Worker routes, so once an app is served only by
6
+ // the `*.9pm.ai/*` wildcard - which, as of 2026-09-21, every app is, none of them holding
7
+ // a Custom Domain any more - such a call gets Cloudflare `1016` while both apps still
8
+ // answer `200` to anyone outside. No external probe sees it, and the app that breaks is
9
+ // not the app that changed.
10
+ //
11
+ // A request from a container's own runtime, or from a browser, is unaffected: those reach
12
+ // the edge like any other client. So this is specifically about server code inside a
13
+ // worker-runtime bundle.
14
+ //
15
+ // A WARNING rather than a refusal, deliberately. A managed hostname has legitimate uses in
16
+ // a bundle - a link handed to the browser, a string in copy, a CSP entry - and refusing on
17
+ // the first false positive teaches people to route around the check rather than read it.
18
+ // The audit on #1237 found every real reference to be a literal, so this catches the
19
+ // mechanically findable case and knowingly misses a hostname arriving from an env var.
20
+ // The platform's own hostnames under the app host base. Server code may call these
21
+ // normally - they are Custom Domains, reached through the edge like any other client - so
22
+ // none of them is a sibling call. Listed explicitly, and checked BEFORE the label pattern,
23
+ // because `custom-origin` satisfies that pattern ("origin" being six alphanumerics): the
24
+ // zone's custom-domain fallback origin would otherwise read as a tester's app, and an app
25
+ // documenting its own domain setup embeds it. The rest escape the pattern only because
26
+ // "staging" is seven characters, which is arithmetic rather than a rule. The apex carries
27
+ // no label at all, so it needs no entry. Kept honest by a test that feeds every hostname
28
+ // the wrangler configs declare through the matcher.
29
+ const PLATFORM_LABELS = new Set([
30
+ "admin",
31
+ "admin-staging",
32
+ "api",
33
+ "api-staging",
34
+ "custom-origin",
35
+ "custom-origin.staging",
36
+ "files",
37
+ "files-staging",
38
+ "staging",
39
+ "www",
40
+ ]);
41
+ // `<slug>-<suffix>[-<marker>]` where the suffix is the last six alphanumerics of the
42
+ // project id, lowercased, and the marker is the environment's label marker - `stg` on
43
+ // staging (`appScriptLabelForProject`). The slug is at least one character and may carry
44
+ // dashes. The suffix is CAPTURED rather than merely matched: it is what tells one app's
45
+ // label from another's, and so what identifies the deploying app's own hostname below.
46
+ const MANAGED_LABEL = /^(?:[a-z0-9]+-)+([a-z0-9]{6})(?:-[a-z0-9]{1,8})?$/;
47
+ // The app host base in production. Declared here rather than taken from cli-config so the
48
+ // tarball needs no new hand-maintained export; callers that serve another base (staging,
49
+ // local) pass it explicitly.
50
+ export const DEFAULT_APP_HOST_BASE = "9pm.ai";
51
+ function normalizeBase(appHostBase) {
52
+ return (appHostBase ?? DEFAULT_APP_HOST_BASE).trim().toLowerCase();
53
+ }
54
+ // The six-character id suffix an app's own label carries (`appScriptLabelForProject`).
55
+ // The own-app exclusion keys on this rather than on the whole hostname because a caller
56
+ // generally cannot build the whole hostname: the slug half is truncated to fit a
57
+ // 63-character DNS label, and by the marker's length again on an environment that adds
58
+ // one - so reproducing it would mean reproducing that arithmetic for an environment the
59
+ // CLI cannot tell it is talking to. Two projects whose ids end in the same six characters
60
+ // would hide one app's warning from the other, which is the cheap direction to be wrong.
61
+ function labelIdSuffix(projectId) {
62
+ return projectId.replace(/[^a-z0-9]/gi, "").slice(-6).toLowerCase();
63
+ }
64
+ /**
65
+ * Every managed app hostname appearing in `source`, the deploying app's own included.
66
+ *
67
+ * Matched on the HOST portion only - a bare hostname, or one inside a URL - so a path or
68
+ * query string that happens to contain the base domain does not register.
69
+ *
70
+ * Separate from the own-app filter below because the deploy path learns the two at
71
+ * different moments: a `--bundle` build is scanned module by module as the size pass
72
+ * streams it, which happens before the project (and so the id that identifies the app's
73
+ * own hostname) exists. Collecting the handful of matches costs nothing to carry; holding
74
+ * the sources until the project resolves would mean holding the whole bundle.
75
+ */
76
+ export function managedHostnamesIn(source, options = {}) {
77
+ const base = normalizeBase(options.appHostBase);
78
+ if (!base) {
79
+ return [];
80
+ }
81
+ // Bounded on both sides by characters that cannot continue a hostname. The right-hand
82
+ // lookahead excludes a DOT as well, so `example.com/my-app-abc123.9pm.ai.txt` - a file
83
+ // on somebody else's domain that happens to contain our base - does not read as a call.
84
+ const pattern = new RegExp(`(?:^|[^a-z0-9.-])([a-z0-9][a-z0-9.-]*)\\.${base.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![a-z0-9.-])`, "gi");
85
+ const found = new Set();
86
+ for (const match of source.toLowerCase().matchAll(pattern)) {
87
+ const label = match[1];
88
+ if (label && !PLATFORM_LABELS.has(label) && MANAGED_LABEL.test(label)) {
89
+ found.add(`${label}.${base}`);
90
+ }
91
+ }
92
+ return [...found].sort();
93
+ }
94
+ /**
95
+ * Of `hostnames`, the ones belonging to some OTHER app.
96
+ *
97
+ * `ownProjectId` is the id of the project being deployed; without it every reference an
98
+ * app makes to ITSELF is reported, which the #1237 audit found to be most of the
99
+ * references on the estate - so the deploy path must not call this before the project
100
+ * resolves.
101
+ */
102
+ export function siblingsOf(hostnames, options = {}) {
103
+ const base = normalizeBase(options.appHostBase);
104
+ const ownSuffix = options.ownProjectId ? labelIdSuffix(options.ownProjectId) : "";
105
+ if (!base || !ownSuffix) {
106
+ return [...new Set(hostnames)].sort();
107
+ }
108
+ const siblings = new Set();
109
+ for (const hostname of hostnames) {
110
+ const label = hostname.toLowerCase().endsWith(`.${base}`) ? hostname.slice(0, -(base.length + 1)) : hostname;
111
+ if (MANAGED_LABEL.exec(label)?.[1] !== ownSuffix) {
112
+ siblings.add(hostname);
113
+ }
114
+ }
115
+ return [...siblings].sort();
116
+ }
117
+ /** `managedHostnamesIn` and `siblingsOf` in one, for a caller holding the whole source. */
118
+ export function siblingHostnamesIn(source, options = {}) {
119
+ return siblingsOf(managedHostnamesIn(source, options), options);
120
+ }
121
+ export function siblingCallWarning(hostnames) {
122
+ const named = hostnames.slice(0, 3).join(", ");
123
+ const rest = hostnames.length > 3 ? ` (and ${hostnames.length - 3} more)` : "";
124
+ return (`Server code references another app's hostname (${named}${rest}). ` +
125
+ "A worker-runtime app cannot reach a sibling app over its public hostname - the request " +
126
+ "does not leave the platform's network and fails with an edge error, even though the " +
127
+ "other app is up. Call it from the browser instead, or from a container's own runtime.");
128
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ninepm",
3
- "version": "0.57.0",
3
+ "version": "0.58.0",
4
4
  "description": "CLI for deploying built apps to 9pm.ai.",
5
5
  "homepage": "https://9pm.ai",
6
6
  "keywords": [