@synopackageland/cli 0.29.0 → 0.31.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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.31.0 (2026-10-25)
4
+
5
+ - Pin templates 0.31.x and SDK/App 0.31.0 defaults, including the hardened executor payloads. Rebuild Apps and obtain administrator approval for changed system-access payloads.
6
+
7
+ - A bare host name now resolves to `https://host:5001` instead of `http://host:5000`; pass a full URL to keep plain HTTP.
8
+ - JSON input duplicate-key detection is now linear-time, so large inputs no longer stall the parser.
9
+ - The CLI's own `.env` loading now sets only `DSM_*` and `SYNOPKGLAND_*` variables; set other variables (for example `GOCACHE` or `PATH`) in the shell.
10
+ - Harden the DSM curl fallback (headers and form fields go through a private curl config; form text is sent literally), human-readable output (control characters are escaped), `ctl` examples and package versions, the UI directory (must resolve inside the project), and standalone inspect (INFO `package` must be an SDK package id).
11
+
12
+ ## 0.30.0 (2026-10-24)
13
+
14
+ - Package and forward `identity_gateway.external_access.single_origin`; use SDK 0.30.0 for one-URL Apps, provider-aware DSM menu launches and launch probes.
15
+ - Pin templates 0.30.x and its SDK/App 0.30.0 defaults; keep immutable executor/v0.27.0.
16
+
3
17
  ## 0.29.0 (2026-10-24)
4
18
 
5
19
  - Depend on templates `~0.29.0` with SDK/App 0.29.0 and UI 0.14.10. SDK 0.29.0 reuses executor/v0.27.0 through executor manifest format 1; no executor digests change.
package/README.md CHANGED
@@ -66,7 +66,7 @@ CLI-only patch releases can differ from the SDK patch. Compose import and SDK de
66
66
  read the SDK pin from the installed templates; explicit App SDK declarations take precedence.
67
67
 
68
68
  `init` and `add` read their templates from the locally installed
69
- `@synopackageland/templates`. CLI 0.29.0 depends on `~0.29.0`: npm installation or
69
+ `@synopackageland/templates`. CLI 0.31.0 depends on `~0.31.0`: npm installation or
70
70
  update can select the latest templates patch in the 0.29 minor line. Templates
71
71
  patches need only a templates release; update the npm installation to receive them.
72
72
  The CLI never fetches templates at runtime, so offline use works after installation;
@@ -308,7 +308,8 @@ and default to `external_access: {connect: optional, origin_change: none}`.
308
308
  Declare `identity_gateway.external_access.connect: none` to ignore Connect's
309
309
  public URL projection; manual URLs remain available without Connect. Use
310
310
  `origin_change: confirm` for Apps that need confirmation when their public
311
- origin changes. `connect: required` is rejected, and declaring `external_access`
311
+ origin changes, and `single_origin: true` (SDK 0.30.0+) for an upstream that
312
+ works on one URL only. `connect: required` is rejected, and declaring `external_access`
312
313
  with SDK older than 0.29.0 is rejected. Compose and gateway SDK pins must match.
313
314
  See [external access configuration](skills/reference/identity-gateway.md#external-access)
314
315
  for the manifest, settings entry, URL rules and cold-login handoff.
@@ -338,7 +339,10 @@ DSM readiness; inspect lists explicit acceptance blockers.
338
339
  ## Control installed Apps
339
340
 
340
341
  Use `synopkgland ctl host add nas --url https://nas.example:5001 --username alice`
341
- to register and authenticate a NAS in OS secure storage. `ctl host list --json`
342
+ to register and authenticate a NAS in OS secure storage. A bare host name uses
343
+ `https://<host>:5001`; provide a full URL for a different scheme or port.
344
+ The CLI loads only `DSM_*` and `SYNOPKGLAND_*` variables from `.env`; set other
345
+ process variables in the shell. `ctl host list --json`
342
346
  checks sessions; `ctl list --host nas --json` discovers all approved task-level
343
347
  commands and their schemas. Run the listed alias/command with typed flags,
344
348
  `--help` and `--dry-run`; writes require `--yes`. There is no mutable current
package/dist/bin.js CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { loadDotEnv } from "./load-env.js";
3
3
  import { runCli } from "./cli.js";
4
+ import { terminalSafe } from "./terminal-text.js";
4
5
  loadDotEnv();
5
6
  // Package deployment is explicit; this CLI does not publish releases.
6
7
  // Successful completion leaves Node's default 0 or the command's exitCode intact.
7
8
  runCli(process.argv).catch((error) => {
8
- console.error(error instanceof Error ? error.message : String(error));
9
+ console.error(terminalSafe(error instanceof Error ? error.message : String(error)));
9
10
  process.exit(1);
10
11
  });
package/dist/cli.js CHANGED
@@ -36,6 +36,7 @@ import { checkJobs, commitOutputs, forEachArch, } from "./standalone/arch-jobs.j
36
36
  import { checkReproducible } from "./reproducible.js";
37
37
  import { spkApprovalDiff, formatApprovalDiff } from "./runtime/approval-diff.js";
38
38
  import { inspectAnySpk } from "./spk/inspect.js";
39
+ import { terminalSafe } from "./terminal-text.js";
39
40
  function failure(code, message, hint = "Fix the configuration and retry; use --help to see command usage.") {
40
41
  return Object.assign(new Error(codedMessage(code, message)), { code, hint });
41
42
  }
@@ -295,7 +296,7 @@ export async function runCli(argv) {
295
296
  const json = argv.includes("--json");
296
297
  const stdout = console.log.bind(console);
297
298
  const progress = console.log;
298
- const emit = (command, data, text) => json ? stdout(JSON.stringify({ ok: true, command, data })) : stdout(text);
299
+ const emit = (command, data, text) => json ? stdout(JSON.stringify({ ok: true, command, data })) : stdout(terminalSafe(text));
299
300
  const program = new Command()
300
301
  .name("synopkgland")
301
302
  .description("Syno Package Land Developer Kit CLI")
package/dist/ctl/cli.js CHANGED
@@ -52,7 +52,7 @@ function q(value) { return "'" + value.replaceAll("'", "'\\''") + "'"; }
52
52
  function example(host, app, op) {
53
53
  const base = `synopkgland ctl ${app.payload.aliases[0] ?? app.payload.package.id} ${op.cli.join(' ')} --host ${host}`;
54
54
  return (op.examples?.length ? op.examples : [{}]).map(params => {
55
- const simple = op.paramsSchema && Object.entries(params).every(([key, value]) => ['string', 'number', 'boolean'].includes(typeof value) && !RESERVED_FLAGS.has(flagName(key)) && op.paramsSchema?.properties?.[key]);
55
+ const simple = op.paramsSchema && Object.entries(params).every(([key, value]) => ['string', 'number', 'boolean'].includes(typeof value) && /^[A-Za-z][A-Za-z0-9_]*$/.test(key) && !RESERVED_FLAGS.has(flagName(key)) && op.paramsSchema?.properties?.[key]);
56
56
  if (simple)
57
57
  return base + Object.entries(params).map(([key, value]) => ' --' + flagName(key) + ' ' + q(String(value))).join('') + (op.effect === 'read' ? '' : ' --yes');
58
58
  return `printf '%s' ${q(JSON.stringify(params))} | ${base} --input -` + (op.effect === 'read' ? '' : ' --yes');
package/dist/ctl/json.js CHANGED
@@ -1,9 +1,43 @@
1
1
  import { openSync, closeSync, readSync } from 'node:fs';
2
- import { parseDocument } from 'yaml';
2
+ /** Linear duplicate-key scan over text that JSON.parse already accepted (yaml's uniqueKeys check is quadratic). */
3
+ function hasDuplicateKeys(text) {
4
+ const stack = [];
5
+ let expectKey = false;
6
+ for (let i = 0; i < text.length; i++) {
7
+ const c = text[i];
8
+ if (c === '"') {
9
+ let j = i + 1;
10
+ while (text[j] !== '"')
11
+ j += text[j] === '\\' ? 2 : 1;
12
+ const top = stack[stack.length - 1];
13
+ if (top && expectKey) {
14
+ const key = JSON.parse(text.slice(i, j + 1));
15
+ if (top.has(key))
16
+ return true;
17
+ top.add(key);
18
+ expectKey = false;
19
+ }
20
+ i = j;
21
+ }
22
+ else if (c === '{') {
23
+ stack.push(new Set());
24
+ expectKey = true;
25
+ }
26
+ else if (c === '[') {
27
+ stack.push(null);
28
+ }
29
+ else if (c === '}' || c === ']') {
30
+ stack.pop();
31
+ }
32
+ else if (c === ',' && stack[stack.length - 1])
33
+ expectKey = true;
34
+ }
35
+ return false;
36
+ }
3
37
  export function strictJson(raw) {
4
38
  const text = typeof raw === 'string' ? raw : new TextDecoder('utf-8', { fatal: true }).decode(raw);
5
39
  const value = JSON.parse(text);
6
- if (parseDocument(text, { uniqueKeys: true }).errors.length)
40
+ if (hasDuplicateKeys(text))
7
41
  throw Error('Duplicate JSON properties');
8
42
  const check = (v, depth) => { if (depth > 32)
9
43
  throw Error('JSON too deep'); if (typeof v === 'number' && !Number.isFinite(v))
@@ -37,7 +37,7 @@ export class CtlClient {
37
37
  const entries = response.data?.packages;
38
38
  if (!Array.isArray(entries) || response.data.total !== entries.length || entries.length > 4096)
39
39
  throw new CtlError('CTL_INVENTORY_INVALID', 'Incomplete installed-package inventory.', 'Check the DSM version and permissions.', 5);
40
- return entries.map((p) => { if (typeof p.id !== 'string' || !/^[A-Za-z][A-Za-z0-9_.+-]{0,127}$/.test(p.id) || typeof p.version !== 'string')
40
+ return entries.map((p) => { if (typeof p.id !== 'string' || !/^[A-Za-z][A-Za-z0-9_.+-]{0,127}$/.test(p.id) || typeof p.version !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._+~-]{0,63}$/.test(p.version))
41
41
  throw new CtlError('CTL_INVENTORY_INVALID', 'Invalid package identity.', 'Check the DSM response.', 5); return { id: p.id, version: p.version, state: p.additional?.status }; });
42
42
  }
43
43
  async discover(options = {}) {
@@ -365,7 +365,7 @@ export const ERROR_REGISTRY = {
365
365
  STANDALONE_IDENTITY_GATEWAY_EXTERNAL_INVALID: {
366
366
  code: "STANDALONE_IDENTITY_GATEWAY_EXTERNAL_INVALID",
367
367
  description: "The external access declaration does not match the gateway contract.",
368
- suggestion: "Set identity_gateway.external_access.connect to optional or none, and origin_change to none or confirm; required is not supported.",
368
+ suggestion: "Set identity_gateway.external_access.connect to optional or none, origin_change to none or confirm, and single_origin to a boolean; required is not supported.",
369
369
  },
370
370
  STANDALONE_IDENTITY_GATEWAY_EXTERNAL_SDK_REQUIRED: {
371
371
  code: "STANDALONE_IDENTITY_GATEWAY_EXTERNAL_SDK_REQUIRED",
@@ -56,7 +56,7 @@ function fromEnv(urlOverride) {
56
56
  if (urlOverride) {
57
57
  dsmOrigin = urlOverride.startsWith("http://") || urlOverride.startsWith("https://")
58
58
  ? urlOverride
59
- : `http://${urlOverride}:5000`;
59
+ : `https://${urlOverride}:5001`;
60
60
  }
61
61
  const password = process.env.DSM_PASSWORD;
62
62
  const username = process.env.DSM_USERNAME ?? "admin";
@@ -52,30 +52,44 @@ export async function dsmFetch(url, init = {}) {
52
52
  return curlFetch(url, init);
53
53
  }
54
54
  }
55
+ function curlConfigString(value) {
56
+ const escapes = { "\\": "\\\\", '"': '\\"', "\t": "\\t", "\n": "\\n", "\r": "\\r", "\v": "\\v" };
57
+ return `"${value.replace(/[\\"\t\n\r\v]/g, (c) => escapes[c])}"`;
58
+ }
55
59
  async function curlFetch(url, init) {
56
60
  const staging = mkdtempSync(join(tmpdir(), "synopkgland-dsm-http-"));
57
61
  const headerFile = join(staging, "headers");
58
62
  const bodyFile = join(staging, "body");
59
63
  try {
60
- const args = ["-sS", "-D", headerFile, "-o", bodyFile, "-w", "%{http_code}", "-X", init.method ?? "GET"];
64
+ const configFile = join(staging, "config");
65
+ const args = ["-sS", "-D", headerFile, "-o", bodyFile, "-w", "%{http_code}", "-X", init.method ?? "GET", "-K", configFile];
66
+ // Headers and form text go through a private config file, not argv, and
67
+ // form text is sent literally (curl -F would read "@file" and "<file").
68
+ const config = [];
61
69
  const headers = headerRecord(init.headers);
62
70
  for (const [name, value] of Object.entries(headers)) {
63
71
  if (name.toLowerCase() === "content-type" && init.body instanceof FormData) {
64
72
  continue;
65
73
  }
66
- args.push("-H", `${name}: ${value}`);
74
+ if (/[\r\n\0]/.test(name + value)) {
75
+ throw new Error(`Invalid header value: ${name}`);
76
+ }
77
+ config.push(`header = ${curlConfigString(`${name}: ${value}`)}`);
67
78
  }
68
79
  let stdin;
69
80
  if (init.body instanceof FormData) {
70
81
  for (const [name, value] of init.body.entries()) {
71
82
  if (typeof value === "string") {
72
- args.push("-F", `${name}=${value}`);
83
+ if (/[=\0]/.test(name) || value.includes("\0")) {
84
+ throw new Error(`Invalid form field: ${name}`);
85
+ }
86
+ config.push(`form-string = ${curlConfigString(`${name}=${value}`)}`);
73
87
  continue;
74
88
  }
75
89
  const filename = "name" in value && typeof value.name === "string" ? value.name : "upload.bin";
76
90
  const partPath = join(staging, filename);
77
91
  writeFileSync(partPath, Buffer.from(await value.arrayBuffer()));
78
- args.push("-F", `${name}=@${partPath};filename=${filename}`);
92
+ config.push(`form = ${curlConfigString(`${name}=@${partPath};filename=${filename}`)}`);
79
93
  }
80
94
  }
81
95
  else if (typeof init.body === "string") {
@@ -86,6 +100,7 @@ async function curlFetch(url, init) {
86
100
  stdin = Buffer.from(await new Response(init.body).arrayBuffer());
87
101
  args.push("--data-binary", "@-");
88
102
  }
103
+ writeFileSync(configFile, config.map((line) => line + "\n").join(""), { mode: 0o600 });
89
104
  args.push(url);
90
105
  const result = spawnSync("curl", args, { encoding: "utf8", input: stdin });
91
106
  if (result.status !== 0) {
package/dist/load-env.js CHANGED
@@ -1,5 +1,8 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
+ // A project .env may only supply the CLI's own settings, never variables that
4
+ // change how child tools (go, docker, curl, tar) run.
5
+ const DOTENV_KEY = /^(?:DSM|SYNOPKGLAND)_[A-Z0-9_]+$/;
3
6
  export function loadDotEnv(fromDir = process.cwd()) {
4
7
  let current = resolve(fromDir);
5
8
  while (true) {
@@ -16,7 +19,7 @@ export function loadDotEnv(fromDir = process.cwd()) {
16
19
  }
17
20
  const key = trimmed.slice(0, eq).trim();
18
21
  const value = trimmed.slice(eq + 1).trim();
19
- if (process.env[key] === undefined) {
22
+ if (DOTENV_KEY.test(key) && process.env[key] === undefined) {
20
23
  process.env[key] = value;
21
24
  }
22
25
  }
@@ -16,6 +16,8 @@ export type GatewayMode = "none" | "oidc" | "header" | "token";
16
16
  export interface GatewayExternalAccess {
17
17
  connect: "optional" | "none";
18
18
  origin_change: "none" | "confirm";
19
+ /** One-URL upstream: DSM launch and local navigations go to the primary external origin. */
20
+ single_origin?: true;
19
21
  }
20
22
  export declare const EXTERNAL_ACCESS_MIN_SDK = "0.29.0";
21
23
  /** SDK 0.28 has the diagnostic ping only; older gateways reject external JSON. */
@@ -167,13 +167,14 @@ export function readIdentityGateway(value, daemon, fail, packagedPayload = false
167
167
  let externalAccess;
168
168
  if (v.external_access !== undefined) {
169
169
  const external = v.external_access;
170
- if (!record(external) || !only(external, ["connect", "origin_change"]) ||
170
+ if (!record(external) || !only(external, ["connect", "origin_change", "single_origin"]) ||
171
171
  (external.connect !== undefined && external.connect !== "optional" && external.connect !== "none") ||
172
- (external.origin_change !== undefined && external.origin_change !== "none" && external.origin_change !== "confirm")) {
173
- fail({ code: "STANDALONE_IDENTITY_GATEWAY_EXTERNAL_INVALID", message: "identity_gateway.external_access accepts only connect (optional or none) and origin_change (none or confirm); required is not supported." });
172
+ (external.origin_change !== undefined && external.origin_change !== "none" && external.origin_change !== "confirm") ||
173
+ (external.single_origin !== undefined && typeof external.single_origin !== "boolean")) {
174
+ fail({ code: "STANDALONE_IDENTITY_GATEWAY_EXTERNAL_INVALID", message: "identity_gateway.external_access accepts only connect (optional or none), origin_change (none or confirm) and single_origin (boolean); required is not supported." });
174
175
  return undefined;
175
176
  }
176
- externalAccess = { connect: external.connect ?? "optional", origin_change: external.origin_change ?? "none" };
177
+ externalAccess = { connect: external.connect ?? "optional", origin_change: external.origin_change ?? "none", ...(external.single_origin ? { single_origin: true } : {}) };
177
178
  }
178
179
  if (v.launch_path !== undefined) {
179
180
  try {
@@ -531,6 +532,10 @@ export async function buildIdentityGateway(m, arch, work, run) {
531
532
  if (!existsSync(config) || !/`json:"external(?:,omitempty)?"`/.test(readFileSync(config, "utf8")) ||
532
533
  !existsSync(main) || !/["']\/?settings\.cgi["']/.test(readFileSync(main, "utf8")))
533
534
  throw new StandaloneBuildError("STANDALONE_IDENTITY_GATEWAY_EXTERNAL_SDK_REQUIRED", `The selected SDK source lacks external access or settings.cgi support; use SDK ${EXTERNAL_ACCESS_MIN_SDK} or later with matching sources.`);
535
+ const external = join(sdk.goDir, "idgateway/external.go");
536
+ if (gatewayExternalAccess(m.identityGateway)?.single_origin &&
537
+ (!existsSync(external) || !/`json:"single_origin(?:,omitempty)?"`/.test(readFileSync(external, "utf8"))))
538
+ throw new StandaloneBuildError("STANDALONE_IDENTITY_GATEWAY_EXTERNAL_SDK_REQUIRED", "The selected SDK source lacks external_access.single_origin support; use a matching SDK release or an explicit development source override.");
534
539
  }
535
540
  if (m.identityGateway.oidc?.browser_redirect) {
536
541
  const capability = join(sdk.goDir, "idgateway/browser_redirect.go");
@@ -1037,6 +1037,11 @@ function readInfo(path, findings, kind, compose = false, daemonNode) {
1037
1037
  findings.push(`INFO is missing ${key}`);
1038
1038
  }
1039
1039
  }
1040
+ // Every SDK build writes this package grammar; inspect renders paths and
1041
+ // configs from it, so anything else is not an SDK-built package.
1042
+ if (info.package && !/^[A-Za-z][A-Za-z0-9]{1,63}$/.test(info.package)) {
1043
+ findings.push("INFO package is not a valid SDK package id");
1044
+ }
1040
1045
  if (kind !== "container" &&
1041
1046
  (info.thirdparty !== "yes" || info.dsmuidir !== "ui")) {
1042
1047
  findings.push('INFO must have thirdparty="yes" and dsmuidir="ui" for DSM to serve /webman/3rdparty/<package>/');
@@ -1135,8 +1140,9 @@ async function inspectExternalGateway(dir, paths, payload, saved, findings) {
1135
1140
  return;
1136
1141
  const external = saved;
1137
1142
  if (!external || typeof external !== "object" || Array.isArray(external) ||
1138
- Object.keys(external).length !== 2 || (external.connect !== "optional" && external.connect !== "none") ||
1143
+ Object.keys(external).length !== (external.single_origin === undefined ? 2 : 3) || (external.connect !== "optional" && external.connect !== "none") ||
1139
1144
  (external.origin_change !== "none" && external.origin_change !== "confirm") ||
1145
+ (external.single_origin !== undefined && external.single_origin !== true) ||
1140
1146
  !isDeepStrictEqual(cfg.external, external) || cfg.package !== info.package)
1141
1147
  throw new Error("external configuration differs from its package declaration");
1142
1148
  const arch = info.arch.split(/\s+/).map(familyOfArchToken).find(Boolean) ?? "x86_64";
@@ -6,9 +6,9 @@ import { resolvedSdkLaunch } from "../contract/app-launch.js";
6
6
  import { gatewayAdmissionPolicy } from "./identity-gateway.js";
7
7
  import { metadataInfoFields } from "../package-metadata.js";
8
8
  import { createHash } from "node:crypto";
9
- import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
9
+ import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, realpathSync, } from "node:fs";
10
10
  import { tmpdir } from "node:os";
11
- import { dirname, join } from "node:path";
11
+ import { dirname, join, sep } from "node:path";
12
12
  import { infoArchValue } from "../build-spk/dsm-arch.js";
13
13
  import { packageIcons, writeDsmWindowIcons, writePackageCenterIcons, } from "../build-spk/dsm-icons.js";
14
14
  import { renderNativePrivilege } from "../build-spk/native-lifecycle.js";
@@ -78,7 +78,7 @@ export async function assembleStandaloneSpk(options) {
78
78
  const uiRoot = join(staging, "package", "ui");
79
79
  const appDir = join(uiRoot, "app");
80
80
  if (hasSdkWindow(manifest))
81
- copyUiTree(manifest.ui.dir, appDir);
81
+ copyUiTree(manifest.ui.dir, appDir, manifest.root);
82
82
  else
83
83
  mkdirSync(appDir, { recursive: true });
84
84
  const assetVersion = digestTree(appDir).slice(0, 12);
@@ -386,12 +386,17 @@ export function renderInfo(manifest, arch, checksum, executorDigest) {
386
386
  fields.push(['startstop_restart_services', 'nginx.service']);
387
387
  return portalInfoFields(fields, Boolean(manifest.portal) || nginx).map(([key, value]) => `${key}=${quote(value)}\n`).join("");
388
388
  }
389
- function copyUiTree(source, target) {
389
+ function copyUiTree(source, target, root) {
390
390
  for (const entry of ["app.js", "app.css"]) {
391
391
  if (!existsSync(join(source, entry))) {
392
392
  throw new StandaloneBuildError("STANDALONE_UI_ENTRY_MISSING", `The UI directory is missing ${entry}: ${source}`);
393
393
  }
394
394
  }
395
+ const real = realpathSync(source);
396
+ const realRoot = realpathSync(root);
397
+ if (real !== realRoot && !real.startsWith(realRoot + sep)) {
398
+ throw new StandaloneBuildError("STANDALONE_UI_SYMLINK", `The UI directory must resolve inside the project: ${source}`);
399
+ }
395
400
  let total = 0;
396
401
  const walk = (from, to) => {
397
402
  mkdirSync(to, { recursive: true });
@@ -0,0 +1 @@
1
+ export declare function terminalSafe(text: string): string;
@@ -0,0 +1,5 @@
1
+ // Package-supplied strings (INFO fields, file names, DSM data) reach human
2
+ // output; show control characters as escapes so they cannot drive the terminal.
3
+ export function terminalSafe(text) {
4
+ return text.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, "0")}`);
5
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.29.0",
3
+ "version": "0.31.0",
4
4
  "description": "Syno Package Land Developer Kit CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "@napi-rs/keyring": "^2.1.1",
30
- "@synopackageland/templates": "~0.29.0",
30
+ "@synopackageland/templates": "~0.31.0",
31
31
  "ajv": "^8.17.1",
32
32
  "commander": "^13.1.0",
33
33
  "json-canonicalize": "^3.0.1",
@@ -2,15 +2,16 @@
2
2
 
3
3
  ## External access
4
4
 
5
- Use SDK 0.29.0 or later with matching gateway sources. Add this to the existing
5
+ Use SDK 0.30.0 or later with matching gateway sources. Add this to the existing
6
6
  complete gateway declaration; keep the App's upstream, listener and auth policy:
7
7
 
8
8
  ```yaml
9
9
  identity_gateway:
10
- sdk: 0.29.0
10
+ sdk: 0.30.0
11
11
  external_access:
12
12
  connect: optional # optional | none
13
13
  origin_change: none # none | confirm
14
+ single_origin: false # true for an upstream that works on one URL only
14
15
  ```
15
16
 
16
17
  Omitting the block or either member uses `connect: optional` and
@@ -38,11 +39,18 @@ installation. It edits only `manual_origin` and `dsm_origin` in private
38
39
  URL fields.
39
40
 
40
41
  Public origins must use HTTPS and a DNS hostname. Port 443 is normalized;
41
- other ports, IP addresses, paths, queries and user information are rejected,
42
+ other ports (except a single-origin App's `manual_origin`), IP addresses, paths, queries and user information are rejected,
42
43
  as is an origin equal to the DSM origin. Manual takes precedence, then the
43
44
  Connect projection when `connect: optional`; the first effective origin is
44
45
  primary. `connect: none` ignores that projection. Private URLs are display-only.
45
46
  Use `origin_change: confirm` for an App that must confirm public-origin changes.
47
+ Declare `single_origin: true` (with `origin_change: confirm`) for an App whose
48
+ upstream sign-in works on one fixed URL, such as Vaultwarden's `DOMAIN`. While
49
+ an external origin is set, the DSM launcher opens the primary origin and local
50
+ browser navigations redirect to it; the settings page says that only that
51
+ address can sign in. Without an external origin nothing changes. The generated
52
+ `external` object then carries `"single_origin":true`, and build rejects a
53
+ development SDK source without `single_origin` support.
46
54
 
47
55
  Cold DSM login preserves external resume tokens
48
56
  `<txn>:x<n>:<r1>.<r2>` in the fixed `authorize.cgi?resume=<encoded token>`
@@ -260,3 +268,5 @@ control characters are rejected. Caller query values cannot override it.
260
268
  Use the upstream's supported entry route; this does not modify upstream UI,
261
269
  produce an App session, or unlock encrypted data. HTTPS and App-specific
262
270
  fragment/query semantics still require deployment acceptance.
271
+
272
+ Single-origin Apps require SDK 0.30.0 or later. Their `manual_origin` may include an explicit HTTPS port (for example, `https://example.ts.net:29443`). The DSM launcher resolves the destination for the provider used to open DSM, probes `/_spl/ping` for up to eight seconds, and offers **Open anyway** if the probe fails. A single-origin App always uses its configured primary origin. Other Apps and the `external-origin` install setting continue to require HTTPS DNS origins on port 443.