ninepm 0.56.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/README.md +1 -0
- package/dist/index.d.ts +8 -3
- package/dist/index.js +63 -14
- package/dist/sibling-hostnames.d.ts +34 -0
- package/dist/sibling-hostnames.js +128 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -76,6 +76,7 @@ Attach an optional short description to the deploy (shown next to the version in
|
|
|
76
76
|
|
|
77
77
|
```sh
|
|
78
78
|
9pm deploy ./dist --name my-app --slug my-app --description "Fix login redirect"
|
|
79
|
+
9pm deploy ./dist --name my-app --slug my-app --owner acme # into an organisation you are an admin of
|
|
79
80
|
```
|
|
80
81
|
|
|
81
82
|
The CLI prints the public app URL after upload and finalization.
|
package/dist/index.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export interface DeployOptions {
|
|
|
7
7
|
token: string;
|
|
8
8
|
name?: string;
|
|
9
9
|
slug?: string;
|
|
10
|
+
owner?: string;
|
|
10
11
|
description?: string;
|
|
11
12
|
environment?: string;
|
|
12
13
|
withDatabase?: boolean;
|
|
@@ -50,6 +51,7 @@ export interface SiteAccessOptions extends ApiOptions {
|
|
|
50
51
|
}
|
|
51
52
|
export interface DeleteSiteOptions extends SiteAccessOptions {
|
|
52
53
|
confirmSlug: string;
|
|
54
|
+
owner?: string;
|
|
53
55
|
keepRecord?: boolean;
|
|
54
56
|
}
|
|
55
57
|
export interface AccessState {
|
|
@@ -114,7 +116,7 @@ export interface CreatedServiceToken {
|
|
|
114
116
|
clientSecret: string;
|
|
115
117
|
}
|
|
116
118
|
export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
117
|
-
export declare const CLI_VERSION = "0.
|
|
119
|
+
export declare const CLI_VERSION = "0.58.0";
|
|
118
120
|
type CredentialBackend = "macos-keychain" | "windows-dpapi" | "linux-secret-service" | "file-plaintext" | "none";
|
|
119
121
|
export interface StoredCredentialEntry {
|
|
120
122
|
token: string;
|
|
@@ -218,7 +220,7 @@ export declare function measureWorkerEntry(source: Uint8Array): {
|
|
|
218
220
|
rawBytes: number;
|
|
219
221
|
compressedBytes: number;
|
|
220
222
|
};
|
|
221
|
-
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<{
|
|
222
224
|
rawBytes: number;
|
|
223
225
|
compressedBytes: number;
|
|
224
226
|
}>;
|
|
@@ -321,7 +323,9 @@ export declare function checkSkillFreshness(options?: {
|
|
|
321
323
|
export interface ListSitesResult {
|
|
322
324
|
projects: Project[];
|
|
323
325
|
}
|
|
324
|
-
export declare function listSites(options: ApiOptions
|
|
326
|
+
export declare function listSites(options: ApiOptions & {
|
|
327
|
+
owner?: string;
|
|
328
|
+
}): Promise<ListSitesResult>;
|
|
325
329
|
export declare function deleteSite(options: DeleteSiteOptions): Promise<DeletedSiteResult>;
|
|
326
330
|
export interface DeviceAuthFeedbackOptions {
|
|
327
331
|
progressStream?: NodeJS.WriteStream;
|
|
@@ -430,6 +434,7 @@ type CliCommand = {
|
|
|
430
434
|
apiUrl: string;
|
|
431
435
|
token: string;
|
|
432
436
|
json: boolean;
|
|
437
|
+
owner?: string;
|
|
433
438
|
} | {
|
|
434
439
|
kind: "logs";
|
|
435
440
|
site: string;
|
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";
|
|
@@ -269,6 +270,7 @@ export async function deployDirectory(initialOptions) {
|
|
|
269
270
|
...(initialOptions.fetchFn ? { fetchFn: initialOptions.fetchFn } : {}),
|
|
270
271
|
site: slugHolder.slug,
|
|
271
272
|
confirmSlug: slugHolder.slug,
|
|
273
|
+
...(initialOptions.owner ? { owner: initialOptions.owner } : {}),
|
|
272
274
|
...(slugHolder.retain ? { keepRecord: true } : {}),
|
|
273
275
|
});
|
|
274
276
|
}
|
|
@@ -339,8 +341,8 @@ async function runDeploy(initialOptions, slugHolder) {
|
|
|
339
341
|
}
|
|
340
342
|
const image = buildContainerImage(options.directory);
|
|
341
343
|
try {
|
|
342
|
-
const projectInput = { name: options.name ?? path.basename(path.resolve(options.directory)) };
|
|
343
|
-
const { project, created: projectCreated } = await createProject(fetchFn, apiUrl, options.token,
|
|
344
|
+
const projectInput = { name: options.name ?? path.basename(path.resolve(options.directory)), ...(options.slug ? { slug: options.slug } : {}), ...(options.owner ? { owner: options.owner } : {}) };
|
|
345
|
+
const { project, created: projectCreated } = await createProject(fetchFn, apiUrl, options.token, projectInput);
|
|
344
346
|
slugHolder.slug = project.slug;
|
|
345
347
|
slugHolder.created = projectCreated;
|
|
346
348
|
slugHolder.neverActive = project.activeDeploymentId === null;
|
|
@@ -490,13 +492,22 @@ async function runDeploy(initialOptions, slugHolder) {
|
|
|
490
492
|
console.warn(`Warning: ${workerEntryNearLimitMessage(serverEntry, rawBytes, compressedBytes)}`);
|
|
491
493
|
}
|
|
492
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();
|
|
493
500
|
// Bundles carry the same budget, applied to all their server modules together (#660).
|
|
494
501
|
// Without this the `--bundle` path — the one the hosted skill's size guidance is about —
|
|
495
502
|
// was the only worker shape with no local size check at all, so an over-budget framework
|
|
496
503
|
// build failed at publish instead, after the project and slug existed.
|
|
497
504
|
if (bundle) {
|
|
498
505
|
const modulePaths = bundleModulePaths(bundle);
|
|
499
|
-
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
|
+
});
|
|
500
511
|
if (compressedBytes > MAX_WORKER_ENTRY_COMPRESSED_BYTES) {
|
|
501
512
|
throw new Error(workerBundleTooLargeMessage(modulePaths.length, rawBytes, compressedBytes));
|
|
502
513
|
}
|
|
@@ -504,10 +515,30 @@ async function runDeploy(initialOptions, slugHolder) {
|
|
|
504
515
|
console.warn(`Warning: ${workerBundleNearLimitMessage(modulePaths.length, rawBytes, compressedBytes)}`);
|
|
505
516
|
}
|
|
506
517
|
}
|
|
507
|
-
const projectInput = { name: options.name ?? path.basename(path.resolve(options.directory)) };
|
|
508
|
-
const { project, created: projectCreated } = await createProject(fetchFn, apiUrl, options.token,
|
|
518
|
+
const projectInput = { name: options.name ?? path.basename(path.resolve(options.directory)), ...(options.slug ? { slug: options.slug } : {}), ...(options.owner ? { owner: options.owner } : {}) };
|
|
519
|
+
const { project, created: projectCreated } = await createProject(fetchFn, apiUrl, options.token, projectInput);
|
|
509
520
|
slugHolder.slug = project.slug;
|
|
510
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
|
+
}
|
|
511
542
|
const database = options.withDatabase
|
|
512
543
|
? await createDatabase(fetchFn, apiUrl, options.token, project.id, {
|
|
513
544
|
name: options.databaseName ?? `${project.slug}-db`,
|
|
@@ -592,7 +623,10 @@ export function measureWorkerEntry(source) {
|
|
|
592
623
|
// It deliberately does not stop early once the limit is passed. The messages report exact
|
|
593
624
|
// byte counts — that precision is what keeps them unambiguous at the boundary — and a floor
|
|
594
625
|
// like "at least N bytes" would give that up to save a second on an already-failing deploy.
|
|
595
|
-
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) {
|
|
596
630
|
const gzip = createGzip();
|
|
597
631
|
let rawBytes = 0;
|
|
598
632
|
let compressedBytes = 0;
|
|
@@ -613,6 +647,7 @@ export async function measureWorkerModuleFiles(root, modulePaths) {
|
|
|
613
647
|
for (const modulePath of modulePaths) {
|
|
614
648
|
const bytes = await readManifestFile(root, modulePath);
|
|
615
649
|
rawBytes += bytes.byteLength;
|
|
650
|
+
onSource?.(new TextDecoder().decode(bytes));
|
|
616
651
|
if (!gzip.write(bytes)) {
|
|
617
652
|
// Respect backpressure so a large bundle cannot balloon the stream's internal queue.
|
|
618
653
|
await once(gzip, "drain");
|
|
@@ -717,6 +752,12 @@ export async function checkDeployDirectory(options) {
|
|
|
717
752
|
const entrySource = await validateWorkerEntrySource(source, serverEntry);
|
|
718
753
|
warnings.push(...entrySource.warnings);
|
|
719
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.
|
|
720
761
|
}
|
|
721
762
|
}
|
|
722
763
|
// Same server-code budget the real deploy applies to a bundle (#660), measured the same
|
|
@@ -1199,11 +1240,13 @@ function readUpdateCheckCache(cachePath) {
|
|
|
1199
1240
|
}
|
|
1200
1241
|
}
|
|
1201
1242
|
export async function listSites(options) {
|
|
1202
|
-
const
|
|
1243
|
+
const route = options.owner ? `/v1/sites?owner=${encodeURIComponent(options.owner)}` : "/v1/sites";
|
|
1244
|
+
const payload = await apiRequest(options, route, { method: "GET" });
|
|
1203
1245
|
return { projects: payload.projects };
|
|
1204
1246
|
}
|
|
1205
1247
|
export async function deleteSite(options) {
|
|
1206
|
-
const
|
|
1248
|
+
const route = `/v1/sites/${encodeURIComponent(options.site)}${options.owner ? `?owner=${encodeURIComponent(options.owner)}` : ""}`;
|
|
1249
|
+
const payload = await apiRequest(options, route, {
|
|
1207
1250
|
method: "DELETE",
|
|
1208
1251
|
body: JSON.stringify({ confirmSlug: options.confirmSlug, ...(options.keepRecord ? { keepRecord: true } : {}) }),
|
|
1209
1252
|
});
|
|
@@ -2154,6 +2197,7 @@ const VALID_DEPLOY_FLAGS = new Set([
|
|
|
2154
2197
|
"email",
|
|
2155
2198
|
"name",
|
|
2156
2199
|
"slug",
|
|
2200
|
+
"owner",
|
|
2157
2201
|
"description",
|
|
2158
2202
|
"environment",
|
|
2159
2203
|
"database-name",
|
|
@@ -2231,7 +2275,8 @@ export function parseArgs(argv) {
|
|
|
2231
2275
|
}
|
|
2232
2276
|
if (command === "apps" || command === "list" || command === "ls" || command === "sites" || command === "projects") {
|
|
2233
2277
|
const flags = parseFlags(argv);
|
|
2234
|
-
|
|
2278
|
+
const owner = readStringFlag(flags, "owner");
|
|
2279
|
+
return { kind: "apps", ...readBaseApiOptions(flags), json: flags.has("json"), ...(owner ? { owner } : {}) };
|
|
2235
2280
|
}
|
|
2236
2281
|
if (command === "latest" || command === "upgrade" || command === "update") {
|
|
2237
2282
|
const flags = parseFlags(argv);
|
|
@@ -2292,6 +2337,7 @@ export function parseArgs(argv) {
|
|
|
2292
2337
|
};
|
|
2293
2338
|
const name = readStringFlag(flags, "name");
|
|
2294
2339
|
const slug = readStringFlag(flags, "slug");
|
|
2340
|
+
const owner = readStringFlag(flags, "owner");
|
|
2295
2341
|
const description = readStringFlag(flags, "description");
|
|
2296
2342
|
const environment = readStringFlag(flags, "environment");
|
|
2297
2343
|
const databaseName = readStringFlag(flags, "database-name");
|
|
@@ -2307,6 +2353,9 @@ export function parseArgs(argv) {
|
|
|
2307
2353
|
if (slug) {
|
|
2308
2354
|
options.slug = slug;
|
|
2309
2355
|
}
|
|
2356
|
+
if (owner) {
|
|
2357
|
+
options.owner = owner;
|
|
2358
|
+
}
|
|
2310
2359
|
let normalizedDescription;
|
|
2311
2360
|
try {
|
|
2312
2361
|
normalizedDescription = normalizeDeployDescription(description);
|
|
@@ -3944,7 +3993,7 @@ async function main() {
|
|
|
3944
3993
|
if (queued) {
|
|
3945
3994
|
console.log(`Status: ${result.deployment.status}. Run 9pm apps to check when it becomes active.`);
|
|
3946
3995
|
}
|
|
3947
|
-
console.log(`Manage: ${PUBLIC_DASHBOARD_URL}`);
|
|
3996
|
+
console.log(`Manage: ${PUBLIC_DASHBOARD_URL}${command.options.owner ? `?owner=${encodeURIComponent(command.options.owner)}` : ""}`);
|
|
3948
3997
|
// Gated the same way as the reachability probe above: a programmatic
|
|
3949
3998
|
// caller injecting fetchFn opted out of the CLI's own network side trips.
|
|
3950
3999
|
if (!command.options.fetchFn) {
|
|
@@ -4144,7 +4193,7 @@ async function main() {
|
|
|
4144
4193
|
return;
|
|
4145
4194
|
}
|
|
4146
4195
|
if (command.kind === "apps") {
|
|
4147
|
-
const result = await listSites({ apiUrl: command.apiUrl, token: command.token });
|
|
4196
|
+
const result = await listSites({ apiUrl: command.apiUrl, token: command.token, ...(command.owner ? { owner: command.owner } : {}) });
|
|
4148
4197
|
if (command.json) {
|
|
4149
4198
|
console.log(JSON.stringify(result, null, 2));
|
|
4150
4199
|
return;
|
|
@@ -4395,7 +4444,7 @@ async function runFilesCommand(command) {
|
|
|
4395
4444
|
function printUsage() {
|
|
4396
4445
|
console.log(`Usage:
|
|
4397
4446
|
9pm --version
|
|
4398
|
-
9pm deploy [directory] --name <site-name> [--slug <slug>] [--description "<note>"] [--with-db] [--with-auth] [--with-email]
|
|
4447
|
+
9pm deploy [directory] --name <site-name> [--slug <slug>] [--owner <organisation>] [--description "<note>"] [--with-db] [--with-auth] [--with-email]
|
|
4399
4448
|
9pm deploy [directory] --runtime worker --entry src/index.js [--with-db]
|
|
4400
4449
|
9pm deploy [directory] --runtime container --port 8080 [--health-path /health] [--region auto] [--with-db] [--always-on] [--volume <gb> | --no-volume] [--cpus <n>] [--memory <size>]
|
|
4401
4450
|
9pm deploy [directory] framework projects (Next.js, Vite) are detected and built automatically; --no-build skips
|
|
@@ -4407,7 +4456,7 @@ function printUsage() {
|
|
|
4407
4456
|
9pm logout [--api-url <url>] [--local] revokes the credential server-side, then removes it from this machine (--local skips the revoke)
|
|
4408
4457
|
9pm doctor [--json]
|
|
4409
4458
|
9pm whoami [--json]
|
|
4410
|
-
9pm apps [--json]
|
|
4459
|
+
9pm apps [--owner <organisation>] [--json]
|
|
4411
4460
|
9pm latest [--json]
|
|
4412
4461
|
9pm support -m "<what happened>" [--subject <subject>] [--site <app>]
|
|
4413
4462
|
9pm files list [--project <site>]
|
|
@@ -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
|
+
}
|