propr-cli 0.8.5 → 0.8.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/README.md +36 -0
- package/dist/commands/tunnelCommand.js +30 -18
- package/dist/orchestrator/manifest.json +10 -10
- package/dist/orchestrator/orchestrator.mjs +35 -21
- package/dist/vendor/shared/index.js +1 -1
- package/dist/vendor/shared/proprCompatibility.js +1 -1
- package/dist/vendor/shared/proprServiceUrls.js +37 -25
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -60,6 +60,42 @@ and exits nonzero when required local stack prerequisites are missing. Scripts
|
|
|
60
60
|
or shell integrations that invoked bare `propr` to print help text should call
|
|
61
61
|
`propr --help` instead.
|
|
62
62
|
|
|
63
|
+
## Hosted UI Tunnel
|
|
64
|
+
|
|
65
|
+
ProPR Connect can provision a managed Cloudflare Tunnel so the hosted UI at
|
|
66
|
+
`https://app.propr.dev` can reach your local stack's API. Run the one-time setup
|
|
67
|
+
command shown in ProPR Connect from your initialized stack directory:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
npm install -g propr-cli@latest
|
|
71
|
+
propr tunnel setup --token <connector-token> --url https://t-<id>.propr.dev --start
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Connect-managed tunnels require `propr-cli` 0.8.6 or newer. The setup command
|
|
75
|
+
writes the tunnel token, instance id, hosted UI origin, public API URL, and
|
|
76
|
+
OAuth callback URL to the stack `.env`, records tunnel mode as enabled, and with
|
|
77
|
+
`--start` starts or recreates the stack so the API picks up the hosted URLs
|
|
78
|
+
immediately. If you have only the instance id, `--instance-id <id>` derives the
|
|
79
|
+
same public URL.
|
|
80
|
+
|
|
81
|
+
The public tunnel origin is always a bare `https://t-<id>.propr.dev` URL; ProPR
|
|
82
|
+
routes only `/api/*` and `/socket.io/*` through it, and the root URL
|
|
83
|
+
intentionally returns 404. The hosted UI itself stays on `https://app.propr.dev`
|
|
84
|
+
and calls the API through the per-instance tunnel host.
|
|
85
|
+
|
|
86
|
+
Useful follow-up commands:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
propr tunnel verify # check cloudflared + /api/status, /, /socket.io/
|
|
90
|
+
propr tunnel off # stop only the sidecar; token/env values stay in .env
|
|
91
|
+
propr tunnel on # restart the sidecar later
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`propr tunnel off` intentionally leaves the Connect-written `.env` values in
|
|
95
|
+
place. If you are switching the same stack back to a local or custom self-hosted
|
|
96
|
+
UI, remove or replace `PROPR_UI_PUBLIC_API_URL`, `API_PUBLIC_URL`,
|
|
97
|
+
`FRONTEND_URL`, and `GH_OAUTH_CALLBACK_URL` before restarting.
|
|
98
|
+
|
|
63
99
|
## Repository Setup
|
|
64
100
|
|
|
65
101
|
Use `propr init` from a repository root to scaffold `.propr` setup files used by agent execution containers.
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { Command } from "commander";
|
|
19
19
|
import { join } from "node:path";
|
|
20
|
-
import { DEFAULT_PROPR_UI_ORIGIN, proprTunnelEndpoints, isProprProxyUrl, PROPR_UI_PROXY_SUFFIX, } from "../vendor/shared/index.js";
|
|
20
|
+
import { DEFAULT_PROPR_UI_ORIGIN, proprInstanceProxyUrl, proprTunnelEndpoints, isProprProxyUrl, PROPR_UI_PROXY_SUFFIX, PROPR_UI_PROXY_LABEL_PREFIX, } from "../vendor/shared/index.js";
|
|
21
21
|
import { createConfigManager } from "../config/index.js";
|
|
22
22
|
import { getHostConfig, resolveStackRoot } from "../orchestrator/index.js";
|
|
23
23
|
import { parseOnOffState, ParseStateError } from "../utils/index.js";
|
|
@@ -34,7 +34,7 @@ export class TunnelTokenMissingError extends Error {
|
|
|
34
34
|
/**
|
|
35
35
|
* Thrown by applyTunnelToggle when `tunnel on` is requested but no public proxy
|
|
36
36
|
* URL can be derived. Hosted UI tunnel mode fundamentally needs an advertised
|
|
37
|
-
* endpoint (`PROPR_INSTANCE_ID` → https
|
|
37
|
+
* endpoint (`PROPR_INSTANCE_ID` → https://t-<id>.propr.dev, or an explicit
|
|
38
38
|
* `PROPR_UI_PUBLIC_API_URL`); without one the sidecar would start and the desired
|
|
39
39
|
* state persist while the hosted UI has no usable endpoint, surfacing only as
|
|
40
40
|
* later status/verify failures. So we refuse up front instead.
|
|
@@ -42,7 +42,7 @@ export class TunnelTokenMissingError extends Error {
|
|
|
42
42
|
export class TunnelPublicUrlMissingError extends Error {
|
|
43
43
|
constructor() {
|
|
44
44
|
super("cannot start the tunnel — no public proxy URL can be derived.\n" +
|
|
45
|
-
" The hosted UI reaches this stack at https
|
|
45
|
+
" The hosted UI reaches this stack at https://t-<id>.propr.dev, so set\n" +
|
|
46
46
|
" PROPR_INSTANCE_ID (preferred) or an explicit PROPR_UI_PUBLIC_API_URL in\n" +
|
|
47
47
|
" your stack .env, then run 'propr tunnel on' again.");
|
|
48
48
|
this.name = "TunnelPublicUrlMissingError";
|
|
@@ -50,7 +50,7 @@ export class TunnelPublicUrlMissingError extends Error {
|
|
|
50
50
|
}
|
|
51
51
|
/**
|
|
52
52
|
* Thrown by applyTunnelToggle when `tunnel on` is requested with a public proxy
|
|
53
|
-
* URL that is not a hosted `https
|
|
53
|
+
* URL that is not a hosted `https://t-<id>.propr.dev` URL. propr-routing only
|
|
54
54
|
* forwards `/api/*` and `/socket.io/*` on those hosts, so an explicit
|
|
55
55
|
* `PROPR_UI_PUBLIC_API_URL` pointing anywhere else (e.g. https://custom.example.com)
|
|
56
56
|
* would start a sidecar the hosted UI cannot route to. The launcher's
|
|
@@ -61,10 +61,10 @@ export class TunnelPublicUrlMissingError extends Error {
|
|
|
61
61
|
export class TunnelPublicUrlInvalidError extends Error {
|
|
62
62
|
constructor(url) {
|
|
63
63
|
super(`cannot start the tunnel — PROPR_UI_PUBLIC_API_URL ("${url}") is not a\n` +
|
|
64
|
-
` hosted proxy URL (https
|
|
64
|
+
` hosted proxy URL (https://${PROPR_UI_PROXY_LABEL_PREFIX}<id>.${PROPR_UI_PROXY_SUFFIX}). The tunnel only\n` +
|
|
65
65
|
` routes /api/* and /socket.io/* on ${PROPR_UI_PROXY_SUFFIX} hosts, so the\n` +
|
|
66
66
|
" hosted UI could not reach this stack. Set PROPR_INSTANCE_ID (preferred) or\n" +
|
|
67
|
-
` a https
|
|
67
|
+
` a https://${PROPR_UI_PROXY_LABEL_PREFIX}<id>.${PROPR_UI_PROXY_SUFFIX} URL, then run 'propr tunnel on' again.`);
|
|
68
68
|
this.name = "TunnelPublicUrlInvalidError";
|
|
69
69
|
}
|
|
70
70
|
}
|
|
@@ -165,7 +165,7 @@ export async function applyTunnelToggle({ enable, cfg, orch, configManager, forc
|
|
|
165
165
|
}
|
|
166
166
|
// A derived public URL is always a well-formed proxy URL, but an explicit
|
|
167
167
|
// PROPR_UI_PUBLIC_API_URL can be anything. propr-routing only forwards /api/*
|
|
168
|
-
// and /socket.io/* on https
|
|
168
|
+
// and /socket.io/* on https://t-<id>.propr.dev hosts, so a non-proxy URL
|
|
169
169
|
// would start a sidecar the hosted UI cannot route to. validateEnv() rejects
|
|
170
170
|
// this for `propr start`/`propr check`; mirror it here so `propr tunnel on`
|
|
171
171
|
// doesn't persist/start an unroutable configuration those commands would refuse.
|
|
@@ -283,8 +283,12 @@ function instanceIdFromProxyUrl(url) {
|
|
|
283
283
|
return undefined;
|
|
284
284
|
}
|
|
285
285
|
const suffix = `.${PROPR_UI_PROXY_SUFFIX}`;
|
|
286
|
-
|
|
287
|
-
|
|
286
|
+
if (parsed.protocol !== "https:" || !parsed.hostname.endsWith(suffix)) {
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
289
|
+
const label = parsed.hostname.slice(0, -suffix.length);
|
|
290
|
+
return label.startsWith(PROPR_UI_PROXY_LABEL_PREFIX)
|
|
291
|
+
? label.slice(PROPR_UI_PROXY_LABEL_PREFIX.length)
|
|
288
292
|
: undefined;
|
|
289
293
|
}
|
|
290
294
|
export function buildTunnelSetupEnv(input) {
|
|
@@ -294,11 +298,14 @@ export function buildTunnelSetupEnv(input) {
|
|
|
294
298
|
const explicitUrl = input.url?.trim().replace(/\/+$/, "");
|
|
295
299
|
const explicitInstanceId = input.instanceId?.trim();
|
|
296
300
|
if (!explicitUrl && !explicitInstanceId) {
|
|
297
|
-
throw new Error("provide --url https
|
|
301
|
+
throw new Error("provide --url https://t-<id>.propr.dev or --instance-id <id>");
|
|
302
|
+
}
|
|
303
|
+
const candidateUrl = explicitUrl ?? proprInstanceProxyUrl(explicitInstanceId);
|
|
304
|
+
if (!candidateUrl) {
|
|
305
|
+
throw new Error(`could not derive a hosted proxy URL from --instance-id (${explicitInstanceId})`);
|
|
298
306
|
}
|
|
299
|
-
const candidateUrl = explicitUrl ?? `https://${explicitInstanceId}.${PROPR_UI_PROXY_SUFFIX}`;
|
|
300
307
|
if (!isProprProxyUrl(candidateUrl)) {
|
|
301
|
-
throw new Error(`tunnel URL must be a bare hosted proxy URL such as https
|
|
308
|
+
throw new Error(`tunnel URL must be a bare hosted proxy URL such as https://${PROPR_UI_PROXY_LABEL_PREFIX}<id>.${PROPR_UI_PROXY_SUFFIX} (no path/query/fragment)`);
|
|
302
309
|
}
|
|
303
310
|
// Canonicalize: URL parsing already lowercases the host, and `.origin` drops
|
|
304
311
|
// any (validated-absent) path so the persisted value matches what the launcher
|
|
@@ -306,11 +313,16 @@ export function buildTunnelSetupEnv(input) {
|
|
|
306
313
|
// mixed-case --instance-id would otherwise diverge from the launcher's value.
|
|
307
314
|
const publicUrl = new URL(candidateUrl).origin;
|
|
308
315
|
const derivedInstanceId = instanceIdFromProxyUrl(publicUrl);
|
|
309
|
-
const
|
|
316
|
+
const normalizedExplicitInstanceId = explicitInstanceId?.startsWith(PROPR_UI_PROXY_LABEL_PREFIX)
|
|
317
|
+
? explicitInstanceId.slice(PROPR_UI_PROXY_LABEL_PREFIX.length)
|
|
318
|
+
: explicitInstanceId;
|
|
319
|
+
const instanceId = (normalizedExplicitInstanceId ?? derivedInstanceId)?.toLowerCase();
|
|
310
320
|
if (!instanceId) {
|
|
311
321
|
throw new Error(`could not derive an instance id from ${publicUrl}`);
|
|
312
322
|
}
|
|
313
|
-
if (derivedInstanceId &&
|
|
323
|
+
if (derivedInstanceId &&
|
|
324
|
+
normalizedExplicitInstanceId &&
|
|
325
|
+
derivedInstanceId.toLowerCase() !== normalizedExplicitInstanceId.toLowerCase()) {
|
|
314
326
|
throw new Error(`--instance-id (${explicitInstanceId}) does not match --url host (${derivedInstanceId})`);
|
|
315
327
|
}
|
|
316
328
|
return {
|
|
@@ -505,20 +517,20 @@ export function createTunnelCommand() {
|
|
|
505
517
|
.option("--root <dir>", "Stack root directory")
|
|
506
518
|
.option("--force", "With 'on', start the tunnel even if the core stack is not running")
|
|
507
519
|
.option("--token <token>", "Connector token from ProPR Connect (setup only)")
|
|
508
|
-
.option("--url <url>", "Public proxy URL from ProPR Connect, e.g. https
|
|
509
|
-
.option("--instance-id <id>", "Instance id from ProPR Connect; derives https
|
|
520
|
+
.option("--url <url>", "Public proxy URL from ProPR Connect, e.g. https://t-<id>.propr.dev (setup only)")
|
|
521
|
+
.option("--instance-id <id>", "Instance id from ProPR Connect; derives https://t-<id>.propr.dev (setup only)")
|
|
510
522
|
.option("--start", "After setup, start or restart the stack with hosted tunnel settings")
|
|
511
523
|
.addHelpText("after", `
|
|
512
524
|
Setup writes the tunnel settings to your stack .env for you:
|
|
513
525
|
|
|
514
|
-
$ propr tunnel setup --token <connector-token> --url https
|
|
526
|
+
$ propr tunnel setup --token <connector-token> --url https://t-<id>.propr.dev --start
|
|
515
527
|
|
|
516
528
|
Starting the tunnel requires a token AND a public proxy URL:
|
|
517
529
|
PROPR_UI_TUNNEL_TOKEN Cloudflare Tunnel token (required to start). This is a
|
|
518
530
|
live Cloudflare credential — do not commit, log, or share it
|
|
519
531
|
PROPR_UI_TUNNEL_ENABLED Explicitly enables the managed tunnel sidecar
|
|
520
532
|
PROPR_INSTANCE_ID Instance id; derives the public URL
|
|
521
|
-
https
|
|
533
|
+
https://t-<id>.propr.dev (required unless
|
|
522
534
|
PROPR_UI_PUBLIC_API_URL is set)
|
|
523
535
|
PROPR_UI_PUBLIC_API_URL Explicit public API URL advertised through the tunnel
|
|
524
536
|
(overrides the id-derived URL)
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.8.
|
|
3
|
-
"git_sha": "
|
|
2
|
+
"version": "0.8.6",
|
|
3
|
+
"git_sha": "cdf74ac2",
|
|
4
4
|
"registry": "propr",
|
|
5
5
|
"images": {
|
|
6
|
-
"app": "propr/app:0.8.
|
|
7
|
-
"ui": "propr/ui:0.8.
|
|
8
|
-
"docs": "propr/docs:0.8.
|
|
9
|
-
"agent-claude": "propr/agent-claude:0.8.
|
|
10
|
-
"agent-codex": "propr/agent-codex:0.8.
|
|
11
|
-
"agent-antigravity": "propr/agent-antigravity:0.8.
|
|
12
|
-
"agent-opencode": "propr/agent-opencode:0.8.
|
|
13
|
-
"agent-vibe": "propr/agent-vibe:0.8.
|
|
6
|
+
"app": "propr/app:0.8.6",
|
|
7
|
+
"ui": "propr/ui:0.8.6",
|
|
8
|
+
"docs": "propr/docs:0.8.6",
|
|
9
|
+
"agent-claude": "propr/agent-claude:0.8.6",
|
|
10
|
+
"agent-codex": "propr/agent-codex:0.8.6",
|
|
11
|
+
"agent-antigravity": "propr/agent-antigravity:0.8.6",
|
|
12
|
+
"agent-opencode": "propr/agent-opencode:0.8.6",
|
|
13
|
+
"agent-vibe": "propr/agent-vibe:0.8.6",
|
|
14
14
|
"redis": "redis:7-alpine",
|
|
15
15
|
"cloudflared": "cloudflare/cloudflared:2024.12.2"
|
|
16
16
|
}
|
|
@@ -26,11 +26,13 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
26
26
|
|
|
27
27
|
// Hosted UI tunnel naming. These mirror the shared TypeScript constants in
|
|
28
28
|
// packages/shared/src/proprServiceUrls.ts (PROPR_UI_PROXY_SUFFIX,
|
|
29
|
-
// DEFAULT_CLOUDFLARED_IMAGE,
|
|
30
|
-
//
|
|
31
|
-
//
|
|
29
|
+
// PROPR_UI_PROXY_LABEL_PREFIX, DEFAULT_CLOUDFLARED_IMAGE,
|
|
30
|
+
// DEFAULT_PROPR_UI_ORIGIN) — kept as plain literals here because this module is
|
|
31
|
+
// dependency-free .mjs (Node stdlib only) and cannot import the TS package.
|
|
32
|
+
// Change one, change the other;
|
|
32
33
|
// test/orchestratorProprUrlsDrift.test.ts guards against the copies diverging.
|
|
33
|
-
export const PROPR_UI_PROXY_SUFFIX = '
|
|
34
|
+
export const PROPR_UI_PROXY_SUFFIX = 'propr.dev';
|
|
35
|
+
export const PROPR_UI_PROXY_LABEL_PREFIX = 't-';
|
|
34
36
|
// Fallback used only when the manifest has no `cloudflared` entry. Pin it to the
|
|
35
37
|
// same tag the manifest ships (docker/launcher/manifest.json) so the effective
|
|
36
38
|
// default is identical whether it comes from the manifest or this fallback —
|
|
@@ -39,28 +41,29 @@ export const DEFAULT_CLOUDFLARED_IMAGE = 'cloudflare/cloudflared:2024.12.2';
|
|
|
39
41
|
export const DEFAULT_PROPR_UI_ORIGIN = 'https://app.propr.dev';
|
|
40
42
|
|
|
41
43
|
// Whether an instance id is a valid single DNS label for the proxy hostname
|
|
42
|
-
// (
|
|
44
|
+
// (t-<id>.propr.dev): 1–63 chars, ASCII letters/digits/hyphens only, no
|
|
43
45
|
// leading/trailing hyphen. Mirrors isValidProprInstanceId() in the shared pkg.
|
|
44
46
|
export function isValidProprInstanceId(instanceId) {
|
|
45
47
|
const id = (instanceId ?? '').trim();
|
|
46
48
|
return /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i.test(id);
|
|
47
49
|
}
|
|
48
50
|
|
|
49
|
-
// Derive the per-instance public API/UI URL (https
|
|
51
|
+
// Derive the per-instance public API/UI URL (https://t-<instanceId>.propr.dev)
|
|
50
52
|
// from an instance id; returns undefined for a missing/blank or invalid id (so a
|
|
51
|
-
// malformed hostname is never emitted).
|
|
52
|
-
//
|
|
53
|
+
// malformed hostname is never emitted). Accepts either the bare id or the public
|
|
54
|
+
// t-<id> DNS label. The id is lowercased so a mixed-case PROPR_INSTANCE_ID
|
|
55
|
+
// yields a canonical hostname (DNS is case-insensitive).
|
|
53
56
|
// Mirrors proprInstanceProxyUrl() in packages/shared/src/proprServiceUrls.ts.
|
|
54
57
|
export function proprInstanceProxyUrl(instanceId) {
|
|
55
|
-
const id = (instanceId
|
|
56
|
-
return isValidProprInstanceId(id) ? `https://${id.toLowerCase()}.${PROPR_UI_PROXY_SUFFIX}` : undefined;
|
|
58
|
+
const id = normalizeProprInstanceId(instanceId);
|
|
59
|
+
return isValidProprInstanceId(id) ? `https://${PROPR_UI_PROXY_LABEL_PREFIX}${id.toLowerCase()}.${PROPR_UI_PROXY_SUFFIX}` : undefined;
|
|
57
60
|
}
|
|
58
61
|
|
|
59
|
-
// Whether a URL is a hosted per-instance proxy URL (https
|
|
62
|
+
// Whether a URL is a hosted per-instance proxy URL (https://t-<id>.propr.dev).
|
|
60
63
|
// propr-routing only forwards /api/* and /socket.io/* on these hosts, so the
|
|
61
|
-
// tunnel base URL must be one of them. Requires exactly one
|
|
62
|
-
// label before the suffix (
|
|
63
|
-
//
|
|
64
|
+
// tunnel base URL must be one of them. Requires exactly one t-<instance-id>
|
|
65
|
+
// label before the suffix (other propr.dev hosts and nested hosts are rejected)
|
|
66
|
+
// and a bare origin (a non-root path/query/fragment is rejected so
|
|
64
67
|
// proprTunnelEndpoints does not double up the /api prefix). Mirrors
|
|
65
68
|
// isProprProxyUrl() in the shared pkg.
|
|
66
69
|
export function isProprProxyUrl(url) {
|
|
@@ -73,12 +76,23 @@ export function isProprProxyUrl(url) {
|
|
|
73
76
|
if (/[^/]/.test(pathname) || search || hash) return false;
|
|
74
77
|
const suffix = `.${PROPR_UI_PROXY_SUFFIX}`;
|
|
75
78
|
if (!hostname.endsWith(suffix)) return false;
|
|
76
|
-
|
|
79
|
+
const label = hostname.slice(0, -suffix.length);
|
|
80
|
+
if (label.includes('.') || !label.startsWith(PROPR_UI_PROXY_LABEL_PREFIX)) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
return isValidProprInstanceId(label.slice(PROPR_UI_PROXY_LABEL_PREFIX.length));
|
|
77
84
|
} catch {
|
|
78
85
|
return false;
|
|
79
86
|
}
|
|
80
87
|
}
|
|
81
88
|
|
|
89
|
+
function normalizeProprInstanceId(instanceId) {
|
|
90
|
+
const id = (instanceId ?? '').trim();
|
|
91
|
+
return id.startsWith(PROPR_UI_PROXY_LABEL_PREFIX)
|
|
92
|
+
? id.slice(PROPR_UI_PROXY_LABEL_PREFIX.length)
|
|
93
|
+
: id;
|
|
94
|
+
}
|
|
95
|
+
|
|
82
96
|
// The concrete endpoints the hosted UI reaches through the tunnel base URL.
|
|
83
97
|
// propr-routing only allows /api/* and /socket.io/*, so the base (root) URL
|
|
84
98
|
// itself intentionally returns 404 — it is NOT a health target; probe apiStatus
|
|
@@ -1458,7 +1472,7 @@ export function validateEnv(cfg) {
|
|
|
1458
1472
|
if (cfg.uiTunnelEnabled && !cfg.uiPublicApiUrl) {
|
|
1459
1473
|
errors.push(
|
|
1460
1474
|
cfg.proprInstanceId
|
|
1461
|
-
? `PROPR_INSTANCE_ID ("${cfg.proprInstanceId}") is not a valid DNS label, so no https
|
|
1475
|
+
? `PROPR_INSTANCE_ID ("${cfg.proprInstanceId}") is not a valid DNS label, so no https://t-<id>.propr.dev URL can be derived. The tunnel would start while the API advertises its localhost URL and the frontend points at the hosted UI, leaving the hosted UI with no endpoint to reach. Set a valid instance id (1–63 letters/digits/hyphens, no leading/trailing hyphen) or an explicit PROPR_UI_PUBLIC_API_URL.`
|
|
1462
1476
|
: 'The UI tunnel is enabled but neither PROPR_INSTANCE_ID nor PROPR_UI_PUBLIC_API_URL is set, so no public proxy URL can be derived. The tunnel would start while the API advertises its localhost URL, leaving the hosted UI with no endpoint to reach. Set PROPR_INSTANCE_ID (preferred) or an explicit PROPR_UI_PUBLIC_API_URL.'
|
|
1463
1477
|
);
|
|
1464
1478
|
}
|
|
@@ -1477,7 +1491,7 @@ export function validateEnv(cfg) {
|
|
|
1477
1491
|
// tunnel is ENABLED the value is advertised to the API/worker, probed by
|
|
1478
1492
|
// getTunnelStatus()/verify, and must point at a hosted proxy host because
|
|
1479
1493
|
// propr-routing only forwards /api/* and /socket.io/* on
|
|
1480
|
-
// https
|
|
1494
|
+
// https://t-<id>.propr.dev — so a non-proxy URL would start an unroutable
|
|
1481
1495
|
// tunnel stack (matching the routing rule and the `propr tunnel on` guard). With
|
|
1482
1496
|
// the tunnel off, any valid http(s) origin the UI should call is legitimate, so
|
|
1483
1497
|
// only the well-formedness check applies.
|
|
@@ -1485,9 +1499,9 @@ export function validateEnv(cfg) {
|
|
|
1485
1499
|
let parsed;
|
|
1486
1500
|
try { parsed = new URL(cfg.uiPublicApiUrl); } catch { /* invalid below */ }
|
|
1487
1501
|
if (!parsed || (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')) {
|
|
1488
|
-
errors.push(`PROPR_UI_PUBLIC_API_URL ("${cfg.uiPublicApiUrl}") is not a valid http(s) URL. It is injected into the UI container (config.js) as the browser's API base URL even when the tunnel is off, so a malformed value breaks the UI. Use a full URL such as https://abc123.
|
|
1502
|
+
errors.push(`PROPR_UI_PUBLIC_API_URL ("${cfg.uiPublicApiUrl}") is not a valid http(s) URL. It is injected into the UI container (config.js) as the browser's API base URL even when the tunnel is off, so a malformed value breaks the UI. Use a full URL such as https://t-abc123.propr.dev.`);
|
|
1489
1503
|
} else if (cfg.uiTunnelEnabled && !isProprProxyUrl(cfg.uiPublicApiUrl)) {
|
|
1490
|
-
errors.push(`PROPR_UI_PUBLIC_API_URL ("${cfg.uiPublicApiUrl}") is not a hosted proxy URL (https
|
|
1504
|
+
errors.push(`PROPR_UI_PUBLIC_API_URL ("${cfg.uiPublicApiUrl}") is not a hosted proxy URL (https://${PROPR_UI_PROXY_LABEL_PREFIX}<id>.${PROPR_UI_PROXY_SUFFIX}). The tunnel only routes /api/* and /socket.io/* on ${PROPR_UI_PROXY_SUFFIX} hosts, so the hosted UI would be unable to reach this stack. Set PROPR_INSTANCE_ID or a bare https://${PROPR_UI_PROXY_LABEL_PREFIX}<id>.${PROPR_UI_PROXY_SUFFIX} origin (no path/query/fragment — the /api and /socket.io paths are appended automatically).`);
|
|
1491
1505
|
}
|
|
1492
1506
|
}
|
|
1493
1507
|
|
|
@@ -1498,7 +1512,7 @@ export function validateEnv(cfg) {
|
|
|
1498
1512
|
// hosted app.propr.dev CORS, cookies, and public links even though the
|
|
1499
1513
|
// cloudflared sidecar itself starts successfully.
|
|
1500
1514
|
if (cfg.uiTunnelEnabled && isLocalhostHttpUrl(cfg.apiPublicUrl)) {
|
|
1501
|
-
errors.push(`API_PUBLIC_URL ("${cfg.apiPublicUrl}") still points at localhost while the UI tunnel is enabled. In tunnel mode the API must advertise the hosted proxy URL (for example https
|
|
1515
|
+
errors.push(`API_PUBLIC_URL ("${cfg.apiPublicUrl}") still points at localhost while the UI tunnel is enabled. In tunnel mode the API must advertise the hosted proxy URL (for example https://${PROPR_UI_PROXY_LABEL_PREFIX}<id>.${PROPR_UI_PROXY_SUFFIX}) so app.propr.dev can reach this stack. Remove the explicit API_PUBLIC_URL or set it to the hosted proxy URL.`);
|
|
1502
1516
|
}
|
|
1503
1517
|
if (cfg.uiTunnelEnabled && isLocalhostHttpUrl(cfg.frontendUrl)) {
|
|
1504
1518
|
errors.push(`FRONTEND_URL ("${cfg.frontendUrl}") still points at localhost while the UI tunnel is enabled. In tunnel mode FRONTEND_URL must be ${DEFAULT_PROPR_UI_ORIGIN} so CORS and redirects allow the hosted UI. Remove the explicit FRONTEND_URL or set it to ${DEFAULT_PROPR_UI_ORIGIN}.`);
|
|
@@ -1510,7 +1524,7 @@ export function validateEnv(cfg) {
|
|
|
1510
1524
|
// hosted UI cannot reach. Warn so the operator updates it (and the GitHub App
|
|
1511
1525
|
// config) to the public proxy callback.
|
|
1512
1526
|
if (cfg.uiTunnelEnabled && /^https?:\/\/(localhost|127\.0\.0\.1)\b/i.test(cfg.ghOauthCallbackUrl)) {
|
|
1513
|
-
warnings.push(`GH_OAUTH_CALLBACK_URL ("${cfg.ghOauthCallbackUrl}") still points at localhost while the UI tunnel is enabled. GitHub OAuth will redirect the browser to a localhost URL the hosted UI cannot reach. Set GH_OAUTH_CALLBACK_URL to your public proxy callback (e.g. https
|
|
1527
|
+
warnings.push(`GH_OAUTH_CALLBACK_URL ("${cfg.ghOauthCallbackUrl}") still points at localhost while the UI tunnel is enabled. GitHub OAuth will redirect the browser to a localhost URL the hosted UI cannot reach. Set GH_OAUTH_CALLBACK_URL to your public proxy callback (e.g. https://${PROPR_UI_PROXY_LABEL_PREFIX}<id>.${PROPR_UI_PROXY_SUFFIX}/api/auth/github/callback) and register it in the GitHub App.`);
|
|
1514
1528
|
}
|
|
1515
1529
|
|
|
1516
1530
|
const hasOpenCodeConfig = Boolean(cfg.hostOpencodeXdgDir);
|
|
@@ -10,7 +10,7 @@ export { validateRelayUrl } from './validateRelayUrl.js';
|
|
|
10
10
|
// Export the hosted propr-routing service default URLs (one source of truth for
|
|
11
11
|
// the webhook.propr.dev host shared by the CLI, the daemon dialer, and the
|
|
12
12
|
// boot/check prerequisite validators)
|
|
13
|
-
export { DEFAULT_PROPR_ROUTING_URL, DEFAULT_PROPR_GH_RELAY_URL, DEFAULT_PROPR_UI_ORIGIN, PROPR_UI_PROXY_SUFFIX, DEFAULT_CLOUDFLARED_IMAGE, proprInstanceProxyUrl, isValidProprInstanceId, isProprProxyUrl, proprTunnelEndpoints, } from './proprServiceUrls.js';
|
|
13
|
+
export { DEFAULT_PROPR_ROUTING_URL, DEFAULT_PROPR_GH_RELAY_URL, DEFAULT_PROPR_UI_ORIGIN, PROPR_UI_PROXY_SUFFIX, PROPR_UI_PROXY_LABEL_PREFIX, DEFAULT_CLOUDFLARED_IMAGE, proprInstanceProxyUrl, isValidProprInstanceId, isProprProxyUrl, proprTunnelEndpoints, } from './proprServiceUrls.js';
|
|
14
14
|
// Export routing URL validation (shared by intake prerequisites and the daemon
|
|
15
15
|
// routing service so the boot/CLI checks and the dialer agree on one policy)
|
|
16
16
|
export { validateRoutingUrl } from './validateRoutingUrl.js';
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// updates packages/shared/package.json or docker/launcher/manifest.json but
|
|
6
6
|
// forgets this constant is caught by the drift test in
|
|
7
7
|
// test/orchestratorProprUrlsDrift.test.ts, which asserts all three agree.
|
|
8
|
-
export const PROPR_VERSION = '0.8.
|
|
8
|
+
export const PROPR_VERSION = '0.8.6';
|
|
9
9
|
// Bump this only when the API/UI contract changes in a way the hosted UI must
|
|
10
10
|
// account for. Patch releases that do not change the browser-facing contract can
|
|
11
11
|
// keep the same compatibility version.
|
|
@@ -27,15 +27,18 @@ export const DEFAULT_PROPR_GH_RELAY_URL = 'https://webhook.propr.dev/v1';
|
|
|
27
27
|
/**
|
|
28
28
|
* Origin of the hosted Propr UI (https://app.propr.dev). This is where the
|
|
29
29
|
* managed control plane is served from; a local stack exposes its own UI on a
|
|
30
|
-
* tunnel under {@link
|
|
30
|
+
* tunnel under a {@link PROPR_UI_PROXY_LABEL_PREFIX} host on
|
|
31
|
+
* {@link PROPR_UI_PROXY_SUFFIX} so the hosted UI can reach it.
|
|
31
32
|
*/
|
|
32
33
|
export const DEFAULT_PROPR_UI_ORIGIN = 'https://app.propr.dev';
|
|
33
34
|
/**
|
|
34
|
-
* DNS suffix for per-instance UI/API tunnel hostnames. Each
|
|
35
|
-
* instance id is reachable at
|
|
36
|
-
* hosted UI at
|
|
35
|
+
* DNS suffix and label prefix for per-instance UI/API tunnel hostnames. Each
|
|
36
|
+
* local stack with an instance id is reachable at
|
|
37
|
+
* `https://t-<instanceId>.propr.dev`, so the hosted UI at
|
|
38
|
+
* {@link DEFAULT_PROPR_UI_ORIGIN} can discover and address it.
|
|
37
39
|
*/
|
|
38
|
-
export const PROPR_UI_PROXY_SUFFIX = '
|
|
40
|
+
export const PROPR_UI_PROXY_SUFFIX = 'propr.dev';
|
|
41
|
+
export const PROPR_UI_PROXY_LABEL_PREFIX = 't-';
|
|
39
42
|
/**
|
|
40
43
|
* Default Cloudflare Tunnel image used to expose the local stack's UI/API to
|
|
41
44
|
* the hosted control plane when a UI tunnel is enabled. This is only a fallback:
|
|
@@ -46,7 +49,7 @@ export const PROPR_UI_PROXY_SUFFIX = 'proxy.propr.dev';
|
|
|
46
49
|
export const DEFAULT_CLOUDFLARED_IMAGE = 'cloudflare/cloudflared:2024.12.2';
|
|
47
50
|
/**
|
|
48
51
|
* Whether an instance id is usable as a single DNS label in the per-instance
|
|
49
|
-
* proxy hostname (
|
|
52
|
+
* proxy hostname (`t-<id>.propr.dev`). Enforces the standard label rules:
|
|
50
53
|
* 1–63 characters, ASCII letters/digits/hyphens only, and no leading or
|
|
51
54
|
* trailing hyphen. This rejects spaces, slashes, dots, underscores, and other
|
|
52
55
|
* characters that would produce an invalid or ambiguous hostname.
|
|
@@ -57,30 +60,31 @@ export function isValidProprInstanceId(instanceId) {
|
|
|
57
60
|
}
|
|
58
61
|
/**
|
|
59
62
|
* Derive the public API/UI URL for a local stack from its instance id, using
|
|
60
|
-
* the shared
|
|
61
|
-
* for instance id `abc123`.
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
63
|
+
* the shared public tunnel host pattern. Returns `https://t-abc123.propr.dev`
|
|
64
|
+
* for instance id `abc123`. A caller may pass either the bare instance id or the
|
|
65
|
+
* public DNS label (`t-abc123`); the returned URL is canonicalized. Returns
|
|
66
|
+
* `undefined` for a missing/blank id — or an id that is not a valid DNS label
|
|
67
|
+
* (see {@link isValidProprInstanceId}) — so callers can fall back to an explicit
|
|
68
|
+
* URL or a local-development default rather than emitting a malformed hostname.
|
|
69
|
+
* The id is lowercased so a mixed-case instance id yields a canonical hostname
|
|
70
|
+
* (DNS is case-insensitive).
|
|
66
71
|
*/
|
|
67
72
|
export function proprInstanceProxyUrl(instanceId) {
|
|
68
|
-
const id = (instanceId
|
|
73
|
+
const id = normalizeProprInstanceId(instanceId);
|
|
69
74
|
if (!isValidProprInstanceId(id))
|
|
70
75
|
return undefined;
|
|
71
|
-
return `https://${id.toLowerCase()}.${PROPR_UI_PROXY_SUFFIX}`;
|
|
76
|
+
return `https://${PROPR_UI_PROXY_LABEL_PREFIX}${id.toLowerCase()}.${PROPR_UI_PROXY_SUFFIX}`;
|
|
72
77
|
}
|
|
73
78
|
/**
|
|
74
|
-
* Whether a URL is a hosted per-instance proxy URL (`https
|
|
79
|
+
* Whether a URL is a hosted per-instance proxy URL (`https://t-<id>.propr.dev`).
|
|
75
80
|
* propr-routing only forwards `/api/*` and `/socket.io/*` on these hosts, so the
|
|
76
81
|
* tunnel base URL must be one of them. Requires https and *exactly one* valid
|
|
77
|
-
* instance-id label in front of the shared {@link PROPR_UI_PROXY_SUFFIX}
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
* malformed URL.
|
|
82
|
+
* `t-<instance-id>` label in front of the shared {@link PROPR_UI_PROXY_SUFFIX}.
|
|
83
|
+
* Other propr.dev hosts like `app.propr.dev` and nested hosts are rejected. It
|
|
84
|
+
* must also be a bare origin: a non-root path, query, or fragment (e.g.
|
|
85
|
+
* `https://t-abc.propr.dev/api`) is rejected because
|
|
86
|
+
* {@link proprTunnelEndpoints} appends `/api/...` itself and a base path would
|
|
87
|
+
* double it up (`.../api/api/status`). Returns false for a malformed URL.
|
|
84
88
|
*/
|
|
85
89
|
export function isProprProxyUrl(url) {
|
|
86
90
|
if (!url)
|
|
@@ -98,14 +102,22 @@ export function isProprProxyUrl(url) {
|
|
|
98
102
|
const suffix = `.${PROPR_UI_PROXY_SUFFIX}`;
|
|
99
103
|
if (!hostname.endsWith(suffix))
|
|
100
104
|
return false;
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
105
|
+
const label = hostname.slice(0, -suffix.length);
|
|
106
|
+
if (label.includes('.') || !label.startsWith(PROPR_UI_PROXY_LABEL_PREFIX)) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
return isValidProprInstanceId(label.slice(PROPR_UI_PROXY_LABEL_PREFIX.length));
|
|
104
110
|
}
|
|
105
111
|
catch {
|
|
106
112
|
return false;
|
|
107
113
|
}
|
|
108
114
|
}
|
|
115
|
+
function normalizeProprInstanceId(instanceId) {
|
|
116
|
+
const id = (instanceId ?? '').trim();
|
|
117
|
+
return id.startsWith(PROPR_UI_PROXY_LABEL_PREFIX)
|
|
118
|
+
? id.slice(PROPR_UI_PROXY_LABEL_PREFIX.length)
|
|
119
|
+
: id;
|
|
120
|
+
}
|
|
109
121
|
/**
|
|
110
122
|
* The concrete endpoints the hosted UI reaches through the tunnel base URL.
|
|
111
123
|
* propr-routing only allows `/api/*` and `/socket.io/*`, so the base (root) URL
|