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 +2 -2
- package/dist/index.js +43 -3
- package/dist/sibling-hostnames.d.ts +34 -0
- package/dist/sibling-hostnames.js +128 -0
- package/package.json +1 -1
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.
|
|
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.
|
|
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
|
+
}
|