From a79628394c2d60c7ec798462a24f3235d7831564 Mon Sep 17 00:00:00 2001 From: Angelo Delefortrie Date: Tue, 1 Sep 2026 15:25:29 +0200 Subject: [PATCH 1/2] Document sharing dynamic access keys with users Add a "Share the Key with Your Users" section to the dynamic access keys page explaining the ssconf:// scheme: how to derive the key from the hosted configuration URL, naming the client entry with a URL fragment, and warnings that the key URL is a credential and that deleting the hosted configuration does not revoke access. Record the resulting code block, link, and admonition differences as known exceptions in the translation verifier, matching how other post-export English additions are handled, and drop a stale exception for vpn/advanced/websockets whose counts already match. Co-Authored-By: Claude Fable 5 --- docs/vpn/management/dynamic-access-keys.md | 48 +++++++++++++++++++++- scripts/verify_translations.py | 9 +++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/docs/vpn/management/dynamic-access-keys.md b/docs/vpn/management/dynamic-access-keys.md index 4a8643311..51de603a8 100644 --- a/docs/vpn/management/dynamic-access-keys.md +++ b/docs/vpn/management/dynamic-access-keys.md @@ -142,4 +142,50 @@ S3](https://docs.aws.amazon.com/AmazonS3/latest/userguide/creating-buckets-s3.ht or [GitHub secret gists](https://docs.github.com/en/get-started/writing-on-github/editing-and-sharing-content-with-gists/creating-gists). Evaluate the specific needs of your deployment and choose a platform that aligns -with your requirements for accessibility and security. \ No newline at end of file +with your requirements for accessibility and security. + +## Share the Key with Your Users + +Hosting the configuration is only half of a dynamic access key. What your users +add to the Outline client is the _location_ of that configuration, written with +the `ssconf://` scheme: take the HTTPS URL the configuration is served from and +replace `https://` with `ssconf://`. + +```none +https://keys.example.com/a1b2c3d4e5f6.yml <- where you host the config +ssconf://keys.example.com/a1b2c3d4e5f6.yml <- the access key you share +``` + +The client replaces the scheme back with `https://` before fetching, so the +host, port, path and query string are all preserved as they are. A plain +`https://` URL is also accepted as a dynamic access key; the `ssconf://` scheme +exists so that the operating system opens the link with the Outline client +instead of a browser. + +You can append a fragment to name the entry in the client's server list: + +```none +ssconf://keys.example.com/a1b2c3d4e5f6.yml#My%20Server +``` + +The client shows this entry as "My Server"; the fragment is not part of the +fetched URL. + +:::caution +A dynamic access key URL is a credential. Anyone who has it can fetch your +configuration and read the secret inside it. Serve it over HTTPS, give each user +their own long, randomly generated path rather than a guessable one such as +`/alice.yml`, and avoid platforms that publish or index what they host, such as +public GitHub repositories or public gists. See [Choose a Hosting +Platform](#choose-a-hosting-platform) for more factors to weigh when picking +where to host. +::: + +:::note +Deleting the hosted configuration does not revoke access: the credentials it +contained remain valid until you remove the key from the server, connected +clients stay connected, and clients that reconnect automatically (at device +boot or app launch) reuse the last configuration they fetched successfully. A +connected client also keeps using the configuration it fetched at connect time; +edits to the hosted file take effect the next time it reconnects. +::: diff --git a/scripts/verify_translations.py b/scripts/verify_translations.py index 05b42f538..b0537aa71 100644 --- a/scripts/verify_translations.py +++ b/scripts/verify_translations.py @@ -54,6 +54,8 @@ "sdk/reference/smart-dialer-config": (10, 9), # 9 code blocks added to MD after translation export (expanded reference) "vpn/reference/access-key-config": (17, 8), + # 2 code blocks added to MD after translation export (ssconf:// key section) + "vpn/management/dynamic-access-keys": (7, 5), } # Known code block content differences: counts match but the content of @@ -76,6 +78,8 @@ "vpn/reference/access-key-config": (39, 28), # 1 link to advanced-config added to MD after translation export "vpn/advanced/caddy": (9, 8), + # 1 anchor link added to MD after translation export (ssconf:// key section) + "vpn/management/dynamic-access-keys": (11, 10), } # Known link order swaps: some translators reordered text, causing link @@ -96,7 +100,10 @@ # Known admonition count differences: English has admonitions added after # translation export. Format: doc_path -> (english_count, translated_count) -KNOWN_ADMONITION_DIFFS: dict[str, tuple[int, int]] = {} +KNOWN_ADMONITION_DIFFS: dict[str, tuple[int, int]] = { + # 2 admonitions added to MD after translation export (ssconf:// key section) + "vpn/management/dynamic-access-keys": (2, 0), +} # Threshold for content parity warnings. If the translation's non-code # content is less than this fraction of the English content length, flag it. From 118e6d3f85b560c960db92982e63f0cd676e180f Mon Sep 17 00:00:00 2001 From: Angelo Delefortrie Date: Fri, 4 Sep 2026 14:25:03 +0200 Subject: [PATCH 2/2] Validate translated links in docs with known English-only links Replace the KNOWN_LINK_COUNT_DIFFS count exceptions, which skipped URL-by-URL link comparison entirely for the affected docs, with a KNOWN_ENGLISH_ONLY_LINKS table pinning the exact English link indices that have no translated counterpart. Those indices are removed before comparison and every remaining link must still match the translation URL-by-URL, so an omitted, mistyped, or replaced localized destination now fails verification. Also validate KNOWN_LINK_SWAPS positions as a set, so a replaced URL at a swapped position is caught instead of being skipped. Co-Authored-By: Claude Fable 5 --- scripts/verify_translations.py | 63 +++++++++++++++++++++------------- 1 file changed, 40 insertions(+), 23 deletions(-) diff --git a/scripts/verify_translations.py b/scripts/verify_translations.py index b0537aa71..25b976177 100644 --- a/scripts/verify_translations.py +++ b/scripts/verify_translations.py @@ -16,8 +16,9 @@ Known acceptable differences are documented and excluded: - Code block counts may differ where English MD was updated after translation export (content added post-export is not in translation HTML sources). -- Link counts may differ where the English MD has footnotes or escaped brackets - that the HTML export did not preserve. +- Specific English links may be absent from translations (footnotes the HTML + export did not preserve, or links added post-export); the remaining links + are still compared URL-by-URL. - Internal links may lack .md extensions (Docusaurus resolves both forms). - Some locales have link order swapped by the translator. """ @@ -68,23 +69,27 @@ "vpn/advanced/prefixing": {0}, } -# Known link count differences: English MD has links (footnotes, escaped -# brackets) that the HTML export did not preserve. -# Format: doc_path -> (english_count, translated_count) -KNOWN_LINK_COUNT_DIFFS = { +# Links that only exist in the English version: added to the MD after +# translation export, or footnotes/escaped brackets the HTML export did not +# preserve. The listed English link indices (0-based, in document order) are +# removed before comparison, after which every remaining link must still +# match the translation URL-by-URL. +# Format: doc_path -> set of English link indices +KNOWN_ENGLISH_ONLY_LINKS: dict[str, set[int]] = { # 2 footnote refs ([^1] in "Alternative[^1]:") not captured by HTML export - "download-links": (16, 14), + "download-links": {13, 15}, # 11 links added to MD after translation export (expanded reference) - "vpn/reference/access-key-config": (39, 28), + "vpn/reference/access-key-config": {0, 1, 20, 21, 22, 33, 34, 35, 36, 37, 38}, # 1 link to advanced-config added to MD after translation export - "vpn/advanced/caddy": (9, 8), + "vpn/advanced/caddy": {3}, # 1 anchor link added to MD after translation export (ssconf:// key section) - "vpn/management/dynamic-access-keys": (11, 10), + "vpn/management/dynamic-access-keys": {10}, } # Known link order swaps: some translators reordered text, causing link -# URLs to appear in a different order. Format: doc_path -> set of link indices -# (0-based) where order may differ. +# URLs to appear in a different order. The URLs at the swapped positions must +# still match as a set. Indices are 0-based positions after English-only +# links have been removed. Format: doc_path -> set of link indices. KNOWN_LINK_SWAPS = { # Wikipedia Base64 link and Google encode/decode toolbox link swapped "vpn/management/dynamic-access-keys": {3, 4}, @@ -453,25 +458,37 @@ def verify_locale(locale: str, english_paths: set[str]) -> list[Issue]: f" (first diff at line {diff_line})" )) - # Link URLs (normalized to strip .md extensions) + # Link URLs (normalized to strip .md extensions). English-only links + # (added after translation export) are removed first so the remaining + # links can still be compared URL-by-URL. en_links = [normalize_link_url(u) for u in extract_link_urls(en_text)] tr_links = [normalize_link_url(u) for u in extract_link_urls(tr_text)] + en_only = KNOWN_ENGLISH_ONLY_LINKS.get(doc_path, set()) + en_links = [u for i, u in enumerate(en_links) if i not in en_only] if len(en_links) != len(tr_links): - known = KNOWN_LINK_COUNT_DIFFS.get(doc_path) - if known and known == (len(en_links), len(tr_links)): - pass # Known acceptable difference - else: - issues.append(Issue( - locale, doc_path, "LINKS", - f"Count mismatch: English has {len(en_links)}, " - f"translation has {len(tr_links)}" - )) + issues.append(Issue( + locale, doc_path, "LINKS", + f"Count mismatch: English has {len(en_links)}" + f"{f' (excluding {len(en_only)} English-only)' if en_only else ''}, " + f"translation has {len(tr_links)}" + )) else: swap_indices = KNOWN_LINK_SWAPS.get(doc_path, set()) + if swap_indices: + # Swapped positions must still hold the same URLs, just + # possibly in a different order. + en_swapped = sorted(en_links[i] for i in swap_indices) + tr_swapped = sorted(tr_links[i] for i in swap_indices) + if en_swapped != tr_swapped: + issues.append(Issue( + locale, doc_path, "LINKS", + f"Swapped links {sorted(swap_indices)} don't match: " + f"English={en_swapped}, translation={tr_swapped}" + )) for idx, (en_url, tr_url) in enumerate(zip(en_links, tr_links)): if en_url != tr_url: if idx in swap_indices: - continue # Known link order swap + continue # Known link order swap (validated above) issues.append(Issue( locale, doc_path, "LINKS", f"Link {idx + 1} differs: "