@rightkit/release 0.2.164 → 0.2.172

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.
Files changed (77) hide show
  1. package/README.md +121 -4
  2. package/addon-command.mjs +6 -13
  3. package/android-aab-scan.mjs +265 -0
  4. package/android-dev.mjs +111 -0
  5. package/android-ndk.mjs +77 -0
  6. package/android-play-upload.mjs +262 -0
  7. package/android-release.mjs +344 -0
  8. package/android-test-fixtures.mjs +79 -0
  9. package/apple-profile-create.mjs +363 -0
  10. package/bin/darwin/rightkit-swap +0 -0
  11. package/bin/darwin/rightkit-swap.sign.json +6 -0
  12. package/build-release.mjs +73 -13
  13. package/cargo-guard.mjs +2 -0
  14. package/cargo-profile-contract.mjs +183 -0
  15. package/cargo-target.mjs +8 -0
  16. package/cli/right-release.mjs +14 -4
  17. package/committed-source.mjs +1 -1
  18. package/dev-artifact-refresh.mjs +176 -0
  19. package/dev-artifact-support.mjs +61 -0
  20. package/development-signing.mjs +71 -4
  21. package/development.mjs +78 -46
  22. package/direct-bootstrap.mjs +26 -41
  23. package/disk-command.mjs +315 -13
  24. package/disk-policy.json +7 -5
  25. package/disk-policy.mjs +141 -22
  26. package/disk-stable-root.mjs +19 -16
  27. package/engine-locator.mjs +116 -0
  28. package/external-bin.mjs +133 -0
  29. package/github-release.mjs +2 -2
  30. package/github-updater-release.mjs +154 -0
  31. package/hardeningscan.mjs +1 -1
  32. package/in-place-install.mjs +56 -0
  33. package/install-app-windows.mjs +63 -0
  34. package/install-app.mjs +53 -23
  35. package/local-publish.mjs +11 -1
  36. package/mac-signing-matrix.mjs +23 -11
  37. package/managed-cargo.mjs +6 -0
  38. package/media-sidecar-catalog.mjs +45 -0
  39. package/media-sidecar.mjs +306 -0
  40. package/ownership-approval-command.mjs +10 -44
  41. package/ownership-approval.mjs +3 -42
  42. package/ownership-catalog-config.json +2 -1
  43. package/ownership-catalog.mjs +4 -2
  44. package/ownership-command.mjs +7 -9
  45. package/ownership-dependency-allowlist.json +25 -1
  46. package/ownership-evidence.mjs +8 -5
  47. package/ownership-git-inventory.mjs +41 -7
  48. package/ownership-lockfiles.mjs +91 -8
  49. package/ownership-mandatory-set.json +36 -16
  50. package/ownership-report.mjs +5 -6
  51. package/ownership-scan-worker.mjs +2 -2
  52. package/ownership-verdict.mjs +10 -7
  53. package/ownership.mjs +47 -29
  54. package/package.json +7 -4
  55. package/portable-development.mjs +32 -115
  56. package/preflight.mjs +4 -6
  57. package/publication-artifacts.mjs +34 -0
  58. package/publish-update.mjs +72 -11
  59. package/qa-adoption-exemptions.json +5 -0
  60. package/qa-build-features.mjs +7 -0
  61. package/qa-contract.mjs +182 -0
  62. package/qa-control-scan.mjs +3 -3
  63. package/release-qa-command.mjs +38 -12
  64. package/release-qa-installed.mjs +136 -0
  65. package/release-qa-receipt.mjs +23 -2
  66. package/release.mjs +283 -14
  67. package/rightkit-ownership.json +537 -5
  68. package/rightkit-sync.mjs +26 -0
  69. package/rightkit-versions.json +110 -62
  70. package/runtime-budget-classes.json +28 -0
  71. package/runtime-budgets.mjs +4 -1
  72. package/sign-candidate.mjs +161 -0
  73. package/sign-dev-artifact.mjs +198 -0
  74. package/stage-binary.mjs +60 -0
  75. package/swap-helper-bundle.mjs +105 -0
  76. package/templates/cargo-profiles.toml +28 -0
  77. package/upload-release.mjs +11 -1
@@ -0,0 +1,45 @@
1
+ // Find the rightkit-ffmpeg crate (its catalog and notice files) for a release.
2
+ //
3
+ // Order: `mediaSidecar.catalog` in the release config (the crate's catalog/artifacts.json; the crate directory is the
4
+ // catalog's grandparent), otherwise the crate Cargo resolves for the app's native manifest (`cargo metadata --offline`,
5
+ // so the catalog is exactly the one the app was compiled against, at the version in its Cargo.lock).
6
+ import { readFileSync } from "node:fs";
7
+ import path from "node:path";
8
+ import { runCargoSync } from "./managed-cargo.mjs";
9
+
10
+ const invalid = (code, message) => Object.assign(new Error(message), { code });
11
+
12
+ export function cargoFfmpegCrate(manifestPath, { run = runCargoSync } = {}) {
13
+ const result = run(["metadata", "--offline", "--format-version", "1", "--manifest-path", manifestPath], {
14
+ cwd: path.dirname(manifestPath),
15
+ encoding: "utf8",
16
+ maxBuffer: 256 * 1024 * 1024,
17
+ windowsHide: true,
18
+ });
19
+ if (result.error) throw invalid("MEDIA_SIDECAR_CRATE_NOT_FOUND", `could not run cargo metadata: ${result.error.message}`);
20
+ if (result.status !== 0) throw invalid("MEDIA_SIDECAR_CRATE_NOT_FOUND", `cargo metadata failed: ${String(result.stderr ?? "").trim()}`);
21
+ const metadata = JSON.parse(result.stdout);
22
+ const crates = metadata.packages.filter((pkg) => pkg.name === "rightkit-ffmpeg");
23
+ if (crates.length !== 1) throw invalid("MEDIA_SIDECAR_CRATE_NOT_FOUND", `the app's dependency graph has ${crates.length} rightkit-ffmpeg packages (expected exactly one)`);
24
+ return { crateDir: path.dirname(crates[0].manifest_path), crateVersion: crates[0].version };
25
+ }
26
+
27
+ /** Returns { catalog, catalogBytes, crateDir, crateVersion }. Injectable: read, crateFor. */
28
+ export function loadSidecarCatalog({ config, root, manifestPath, read = readFileSync, crateFor = cargoFfmpegCrate }) {
29
+ let crateDir;
30
+ let crateVersion;
31
+ let catalogPath;
32
+ if (config.catalog) {
33
+ catalogPath = path.resolve(root, config.catalog);
34
+ crateDir = path.dirname(path.dirname(catalogPath));
35
+ } else {
36
+ if (!manifestPath) throw invalid("MEDIA_SIDECAR_CRATE_NOT_FOUND", "no mediaSidecar.catalog and no native Cargo manifest to resolve rightkit-ffmpeg from");
37
+ ({ crateDir, crateVersion } = crateFor(manifestPath));
38
+ catalogPath = path.join(crateDir, "catalog", "artifacts.json");
39
+ }
40
+ let catalogBytes;
41
+ try { catalogBytes = read(catalogPath); } catch (error) { throw invalid("MEDIA_SIDECAR_CATALOG_INVALID", `cannot read ${catalogPath}: ${error.message}`); }
42
+ let catalog;
43
+ try { catalog = JSON.parse(String(catalogBytes)); } catch { throw invalid("MEDIA_SIDECAR_CATALOG_INVALID", `${catalogPath} is not JSON`); }
44
+ return { catalog, catalogBytes, crateDir, crateVersion };
45
+ }
@@ -0,0 +1,306 @@
1
+ // Bundle the rightkit-ffmpeg decode sidecar (ffmpeg + ffprobe) into a desktop app and seal its identity.
2
+ //
3
+ // targets.mac.mediaSidecar = {
4
+ // arch: "aarch64", // catalog arch (default: build host); one arch per build
5
+ // variant: "decode", // catalog variant (default "decode")
6
+ // tools: ["ffmpeg", "ffprobe"], // default both
7
+ // stageDir: "src-tauri/binaries", // Tauri externalBin directory (default)
8
+ // bundleDir: "Contents/MacOS", // mac only: where Tauri puts externalBin (or "Contents/Resources")
9
+ // notices: { dir: "src-tauri/resources/third-party/ffmpeg", sourceOfferContact: "https://..." },
10
+ // catalog: "path/to/artifacts.json", // optional; default: the rightkit-ffmpeg crate Cargo resolves
11
+ // stage: { cmd: "cargo", args: [...] }, // optional: runs rightkit_ffmpeg::stage_external_bin
12
+ // }
13
+ //
14
+ // This module owns the config shape, catalog row selection, the notice set, the pre-sign checks and the receipt.
15
+ // It never signs and never hashes the catalog's content digest (the crate's stage_external_bin does that when it
16
+ // copies the binary in); release.mjs signs the files with the app and build-release.mjs seals the receipt.
17
+ import { createHash } from "node:crypto";
18
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync, copyFileSync } from "node:fs";
19
+ import path from "node:path";
20
+
21
+ export const MEDIA_SIDECAR_CONTRACT = "rightkit-ffmpeg-sidecar-v1";
22
+ export const MEDIA_SIDECAR_RECEIPT_NAME = "media-sidecar.json";
23
+ export const MEDIA_SIDECAR_OFFER_NAME = "FFMPEG-SOURCE-OFFER.txt";
24
+ export const MEDIA_SIDECAR_TOOLS = Object.freeze(["ffmpeg", "ffprobe"]);
25
+
26
+ const PLATFORM_OS = Object.freeze({ mac: "macos", win: "windows", linux: "linux" });
27
+ const ARCHES = new Set(["aarch64", "x86_64"]);
28
+ const SHA256 = /^[0-9a-f]{64}$/;
29
+ const CONFIG_KEYS = new Set(["arch", "variant", "tools", "stageDir", "bundleDir", "notices", "catalog", "stage"]);
30
+ const BUNDLE_DIRS = new Set(["Contents/MacOS", "Contents/Resources"]);
31
+
32
+ const invalid = (code, message) => Object.assign(new Error(message), { code });
33
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
34
+ const sha256Bytes = (bytes) => createHash("sha256").update(bytes).digest("hex");
35
+ const sha256File = (file) => sha256Bytes(readFileSync(file));
36
+
37
+ /** Tauri target triple for a catalog os/arch pair (same table as rightkit-ffmpeg `tauri_target_triple`). */
38
+ export function sidecarTargetTriple(os, arch) {
39
+ const table = {
40
+ "macos/aarch64": "aarch64-apple-darwin",
41
+ "macos/x86_64": "x86_64-apple-darwin",
42
+ "windows/x86_64": "x86_64-pc-windows-msvc",
43
+ "windows/aarch64": "aarch64-pc-windows-msvc",
44
+ "linux/x86_64": "x86_64-unknown-linux-gnu",
45
+ "linux/aarch64": "aarch64-unknown-linux-gnu",
46
+ };
47
+ const triple = table[`${os}/${arch}`];
48
+ if (!triple) throw invalid("MEDIA_SIDECAR_PLATFORM", `no Tauri target triple for ${os}/${arch}`);
49
+ return triple;
50
+ }
51
+
52
+ /** `ffmpeg-<triple>[.exe]`: the file Tauri resolves for `externalBin: ["binaries/ffmpeg"]`. */
53
+ export function stagedFileName(tool, os, arch) {
54
+ return `${tool}-${sidecarTargetTriple(os, arch)}${os === "windows" ? ".exe" : ""}`;
55
+ }
56
+
57
+ /** The name Tauri gives the binary inside the bundle (no triple). */
58
+ export function bundledFileName(tool, os) {
59
+ return `${tool}${os === "windows" ? ".exe" : ""}`;
60
+ }
61
+
62
+ export function hostCatalogArch(nodeArch = process.arch) {
63
+ return nodeArch === "arm64" ? "aarch64" : nodeArch === "x64" ? "x86_64" : nodeArch;
64
+ }
65
+
66
+ /**
67
+ * Validate and normalise `targets.<platform>.mediaSidecar`. Every problem is reported at once.
68
+ * Returns { os, arch, variant, tools, stageDir, bundleDir, notices: { dir, sourceOfferContact }, catalog?, stage? }.
69
+ */
70
+ export function validateMediaSidecarConfig(raw, { platform, hostArch = hostCatalogArch() } = {}) {
71
+ const errors = [];
72
+ const os = PLATFORM_OS[platform];
73
+ if (!os) throw invalid("MEDIA_SIDECAR_CONFIG_INVALID", `mediaSidecar is not supported on platform ${JSON.stringify(platform)} (mac, win or linux)`);
74
+ if (!isObject(raw)) throw invalid("MEDIA_SIDECAR_CONFIG_INVALID", "mediaSidecar must be an object");
75
+ for (const key of Object.keys(raw)) if (!CONFIG_KEYS.has(key)) errors.push(`unknown key ${JSON.stringify(key)} (allowed: ${[...CONFIG_KEYS].join(", ")})`);
76
+ const relativePath = (value, label) => {
77
+ if (typeof value !== "string" || !value.trim()) { errors.push(`${label} must be a non-empty relative path`); return undefined; }
78
+ const normalized = value.replaceAll("\\", "/");
79
+ if (path.isAbsolute(value) || /^[A-Za-z]:/.test(value) || normalized.split("/").includes("..")) { errors.push(`${label} must be relative and stay inside the app: ${value}`); return undefined; }
80
+ return normalized.replace(/\/+$/, "");
81
+ };
82
+ const arch = raw.arch ?? hostArch;
83
+ if (!ARCHES.has(arch)) errors.push(`arch must be one of ${[...ARCHES].join(", ")} (one architecture per build): ${JSON.stringify(arch)}`);
84
+ const variant = raw.variant ?? "decode";
85
+ if (typeof variant !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(variant)) errors.push(`variant must be a catalog variant slug: ${JSON.stringify(variant)}`);
86
+ const tools = raw.tools ?? [...MEDIA_SIDECAR_TOOLS];
87
+ if (!Array.isArray(tools) || !tools.length || new Set(tools).size !== tools.length || tools.some((tool) => !MEDIA_SIDECAR_TOOLS.includes(tool))) {
88
+ errors.push(`tools must be a non-empty list without duplicates drawn from ${MEDIA_SIDECAR_TOOLS.join(", ")}`);
89
+ }
90
+ const stageDir = relativePath(raw.stageDir ?? "src-tauri/binaries", "stageDir");
91
+ let bundleDir;
92
+ if (os === "macos") {
93
+ bundleDir = raw.bundleDir ?? "Contents/MacOS";
94
+ if (!BUNDLE_DIRS.has(bundleDir)) errors.push(`bundleDir must be ${[...BUNDLE_DIRS].join(" or ")}`);
95
+ } else if (raw.bundleDir !== undefined) errors.push("bundleDir applies to macOS only");
96
+ let notices;
97
+ if (!isObject(raw.notices)) errors.push("notices must be { dir, sourceOfferContact }: the LGPL text and source offer must ship with the app");
98
+ else {
99
+ for (const key of Object.keys(raw.notices)) if (!["dir", "sourceOfferContact"].includes(key)) errors.push(`notices: unknown key ${JSON.stringify(key)}`);
100
+ const dir = relativePath(raw.notices.dir, "notices.dir");
101
+ const contact = raw.notices.sourceOfferContact;
102
+ if (typeof contact !== "string" || !/^(https:\/\/\S+|[^\s@]+@[^\s@]+\.[^\s@]+)$/.test(contact)) errors.push("notices.sourceOfferContact must be an https URL or an email address where the source can be requested");
103
+ if (dir) notices = { dir, sourceOfferContact: contact };
104
+ }
105
+ const catalog = raw.catalog === undefined ? undefined : relativePath(raw.catalog, "catalog");
106
+ let stage;
107
+ if (raw.stage !== undefined) {
108
+ if (!isObject(raw.stage) || typeof raw.stage.cmd !== "string" || !raw.stage.cmd || (raw.stage.args !== undefined && !Array.isArray(raw.stage.args))) errors.push("stage must be { cmd, args? }");
109
+ else stage = raw.stage;
110
+ }
111
+ if (errors.length) throw invalid("MEDIA_SIDECAR_CONFIG_INVALID", `invalid mediaSidecar configuration:\n${errors.map((line) => `- ${line}`).join("\n")}`);
112
+ return { os, arch, variant, tools: [...tools], stageDir, ...(bundleDir ? { bundleDir } : {}), notices, ...(catalog ? { catalog } : {}), ...(stage ? { stage } : {}) };
113
+ }
114
+
115
+ /** `sign.nestedCode` entries for the macOS bundle: the sidecar executables, hardened runtime, no entitlements. */
116
+ export function mediaSidecarNestedCode(config) {
117
+ if (config.os !== "macos") return {};
118
+ return Object.fromEntries(config.tools.map((tool) => [`${config.bundleDir}/${bundledFileName(tool, "macos")}`, { hardenedRuntime: true }]));
119
+ }
120
+
121
+ /**
122
+ * Select the one approved catalog row per tool for this variant/os/arch. A missing row, a pending or stale row, or an
123
+ * approved row without a content digest refuses the release: Linux has no approved row yet, so a Linux plan fails
124
+ * here with MEDIA_SIDECAR_NO_ROW instead of shipping an unverified binary.
125
+ */
126
+ export function selectSidecarRows(catalog, config) {
127
+ if (!isObject(catalog) || !Array.isArray(catalog.artifacts)) throw invalid("MEDIA_SIDECAR_CATALOG_INVALID", "catalog has no artifacts array");
128
+ return config.tools.map((tool) => {
129
+ const matches = catalog.artifacts.filter((row) => row.tool === tool && (row.variant ?? "decode") === config.variant
130
+ && String(row.os).toLowerCase() === config.os && String(row.arch).toLowerCase() === config.arch);
131
+ const where = `${tool} ${config.variant} ${config.os}/${config.arch}`;
132
+ if (matches.length === 0) throw invalid("MEDIA_SIDECAR_NO_ROW", `the rightkit-ffmpeg catalog has no ${where} row`);
133
+ if (matches.length > 1) throw invalid("MEDIA_SIDECAR_AMBIGUOUS_ROW", `the rightkit-ffmpeg catalog has ${matches.length} ${where} rows`);
134
+ const [row] = matches;
135
+ if (row.status !== "approved") throw invalid("MEDIA_SIDECAR_NOT_APPROVED", `the ${where} row ${row.id} is ${row.status}, not approved`);
136
+ if (!SHA256.test(row.contentSha256 ?? "")) throw invalid("MEDIA_SIDECAR_NOT_APPROVED", `the ${where} row ${row.id} is approved but has no content digest`);
137
+ if (!Array.isArray(row.attribution?.noticeFiles) || !row.attribution.noticeFiles.length || typeof row.attribution.sourceUrl !== "string") {
138
+ throw invalid("MEDIA_SIDECAR_NOTICE_MISSING", `the ${where} row ${row.id} lacks notice files or a source URL`);
139
+ }
140
+ return row;
141
+ });
142
+ }
143
+
144
+ /**
145
+ * Plan the sidecar for one build. `catalog` is the parsed artifacts.json, `crateDir` the rightkit-ffmpeg crate
146
+ * directory (notice files are relative to it), `root` the app root.
147
+ */
148
+ export function planMediaSidecar({ config, catalog, crateDir, crateVersion, catalogBytes, root }) {
149
+ const rows = selectSidecarRows(catalog, config);
150
+ const files = rows.map((row) => ({
151
+ tool: row.tool,
152
+ artifactId: row.id,
153
+ version: row.version,
154
+ contentSha256: row.contentSha256,
155
+ stagedPath: path.resolve(root, config.stageDir, stagedFileName(row.tool, config.os, config.arch)),
156
+ bundledName: bundledFileName(row.tool, config.os),
157
+ ...(config.bundleDir ? { bundlePath: `${config.bundleDir}/${bundledFileName(row.tool, config.os)}` } : {}),
158
+ }));
159
+ const noticeSources = new Map();
160
+ for (const row of rows) for (const notice of row.attribution.noticeFiles) {
161
+ const normalized = String(notice).replaceAll("\\", "/");
162
+ if (path.isAbsolute(notice) || normalized.split("/").includes("..")) throw invalid("MEDIA_SIDECAR_NOTICE_MISSING", `notice file path escapes the crate: ${notice}`);
163
+ noticeSources.set(normalized, path.join(crateDir, ...normalized.split("/")));
164
+ }
165
+ const noticeDir = path.resolve(root, config.notices.dir);
166
+ const notices = [...noticeSources].map(([relative, source]) => ({ source, dest: path.join(noticeDir, path.basename(relative)) }));
167
+ const names = notices.map((item) => path.basename(item.dest));
168
+ if (new Set(names).size !== names.length) throw invalid("MEDIA_SIDECAR_NOTICE_MISSING", "two notice files share a basename");
169
+ return {
170
+ config,
171
+ rows,
172
+ files,
173
+ notices,
174
+ offerPath: path.join(noticeDir, MEDIA_SIDECAR_OFFER_NAME),
175
+ offer: sourceOfferText({ rows, contact: config.notices.sourceOfferContact, crateVersion }),
176
+ catalog: { crateVersion: crateVersion ?? null, schemaVersion: catalog.schemaVersion ?? null, ...(catalogBytes ? { sha256: sha256Bytes(catalogBytes) } : {}) },
177
+ };
178
+ }
179
+
180
+ /** The written LGPL source offer: where the exact FFmpeg source, the build recipe and the licence text are. */
181
+ export function sourceOfferText({ rows, contact, crateVersion }) {
182
+ const lines = [
183
+ "FFmpeg source offer",
184
+ "",
185
+ "This application includes unmodified FFmpeg programs (ffmpeg and ffprobe) built from the FFmpeg source below. FFmpeg is licensed under the",
186
+ "GNU Lesser General Public License version 2.1 or later (LGPL-2.1-or-later); the build is configured with --disable-gpl and --disable-nonfree.",
187
+ "The licence text and build notes are in this folder. You may replace these programs with your own build of the same source.",
188
+ "",
189
+ `Source code and build recipe: ${rows[0].attribution.sourceUrl}`,
190
+ `Build recipes, pinned sources and notes: https://github.com/Orthic-Labs/rightkit/tree/main/crates/rightkit-ffmpeg${crateVersion ? ` (crate rightkit-ffmpeg ${crateVersion}, published on crates.io)` : ""}`,
191
+ `To request the corresponding source on physical media, or if a link is unavailable, write to: ${contact}`,
192
+ "",
193
+ "Programs shipped",
194
+ ];
195
+ for (const row of rows) {
196
+ lines.push(`- ${row.tool} ${row.version} (${row.id})`);
197
+ lines.push(` licence: ${row.attribution.licenseSpdx}`);
198
+ lines.push(` source: ${row.attribution.sourceUrl}`);
199
+ lines.push(` source archive sha256: ${row.sourceSha256}`);
200
+ lines.push(` build notes: ${row.attribution.buildProvenance}`);
201
+ for (const component of row.attribution.components ?? []) lines.push(` component ${component.name} ${component.version}: ${component.licenseSpdx} (${component.sourceUrl})`);
202
+ }
203
+ return `${lines.join("\n")}\n`;
204
+ }
205
+
206
+ /** Copy the catalog's notice files and write the source offer into the app's notice directory. Idempotent. */
207
+ export function writeMediaSidecarNotices(plan, { exists = existsSync, mkdir = mkdirSync, copy = copyFileSync, write = writeFileSync } = {}) {
208
+ for (const { source } of plan.notices) {
209
+ if (!exists(source)) throw invalid("MEDIA_SIDECAR_NOTICE_MISSING", `notice file not found in the rightkit-ffmpeg crate: ${source}`);
210
+ }
211
+ mkdir(path.dirname(plan.offerPath), { recursive: true });
212
+ for (const { source, dest } of plan.notices) copy(source, dest);
213
+ write(plan.offerPath, plan.offer);
214
+ return [...plan.notices.map((item) => item.dest), plan.offerPath];
215
+ }
216
+
217
+ /** Before signing: every staged binary exists, is a non-empty file, and its hash is recorded. */
218
+ export function verifyStagedSidecars(plan, { exists = existsSync, stat = (file) => statSync(file), hashFile = sha256File } = {}) {
219
+ return plan.files.map((file) => {
220
+ if (!exists(file.stagedPath)) throw invalid("MEDIA_SIDECAR_NOT_STAGED", `${file.tool} is not staged: expected ${file.stagedPath} (rightkit_ffmpeg::stage_external_bin writes it; configure mediaSidecar.stage or stage it in prePackage)`);
221
+ const info = stat(file.stagedPath);
222
+ if (!info.isFile() || info.size <= 0) throw invalid("MEDIA_SIDECAR_NOT_STAGED", `${file.stagedPath} is not a non-empty file`);
223
+ return { ...file, preSignSha256: hashFile(file.stagedPath), preSignSizeBytes: info.size };
224
+ });
225
+ }
226
+
227
+ /** After signing: the receipt that build-release.mjs seals into the release manifest. `signed` maps tool to { sha256, sizeBytes }. */
228
+ export function buildMediaSidecarReceipt(plan, preSign, signed, { notices = [], hashFile = sha256File, signing } = {}) {
229
+ const config = plan.config;
230
+ return {
231
+ schema: 1,
232
+ contract: MEDIA_SIDECAR_CONTRACT,
233
+ variant: config.variant,
234
+ os: config.os,
235
+ arch: config.arch,
236
+ signing: signing ?? (config.os === "macos" ? "developer-id-hardened-runtime" : config.os === "windows" ? "authenticode" : "none"),
237
+ catalog: plan.catalog,
238
+ files: preSign.map((file) => {
239
+ const after = signed[file.tool];
240
+ if (!after || !SHA256.test(after.sha256 ?? "")) throw invalid("MEDIA_SIDECAR_RECEIPT_INVALID", `no signed digest for ${file.tool}`);
241
+ return {
242
+ tool: file.tool,
243
+ artifactId: file.artifactId,
244
+ version: file.version,
245
+ catalogContentSha256: file.contentSha256,
246
+ preSignSha256: file.preSignSha256,
247
+ signedSha256: after.sha256,
248
+ sizeBytes: after.sizeBytes,
249
+ ...(file.bundlePath ? { bundlePath: file.bundlePath } : { bundledName: file.bundledName }),
250
+ };
251
+ }),
252
+ notices: [...plan.notices.map((item) => item.dest), plan.offerPath].map((file) => ({ name: path.basename(file), sha256: hashFile(file) })),
253
+ sourceOfferContact: config.notices.sourceOfferContact,
254
+ ...(notices.length ? { extra: notices } : {}),
255
+ };
256
+ }
257
+
258
+ /** Structural check used by the seal and by publication. `expected` optionally pins variant, os and tools. */
259
+ export function validateMediaSidecarReceipt(receipt, expected = {}) {
260
+ const fail = (message) => { throw invalid("MEDIA_SIDECAR_RECEIPT_INVALID", `media sidecar receipt: ${message}`); };
261
+ if (!isObject(receipt) || receipt.schema !== 1 || receipt.contract !== MEDIA_SIDECAR_CONTRACT) fail("unrecognized contract");
262
+ if (!Array.isArray(receipt.files) || !receipt.files.length) fail("no files");
263
+ for (const file of receipt.files) {
264
+ if (!MEDIA_SIDECAR_TOOLS.includes(file.tool) || typeof file.artifactId !== "string" || !file.artifactId) fail(`bad file entry ${JSON.stringify(file.tool)}`);
265
+ for (const key of ["catalogContentSha256", "preSignSha256", "signedSha256"]) if (!SHA256.test(file[key] ?? "")) fail(`${file.tool}.${key} must be a SHA-256`);
266
+ if (!Number.isSafeInteger(file.sizeBytes) || file.sizeBytes <= 0) fail(`${file.tool}.sizeBytes must be positive`);
267
+ if (receipt.signing !== "none" && file.preSignSha256 === file.signedSha256) fail(`${file.tool} was not changed by signing`);
268
+ }
269
+ if (new Set(receipt.files.map((file) => file.tool)).size !== receipt.files.length) fail("duplicate tool");
270
+ if (!["developer-id-hardened-runtime", "authenticode", "none"].includes(receipt.signing)) fail("unknown signing mode");
271
+ if (!Array.isArray(receipt.notices) || !receipt.notices.some((item) => item.name === MEDIA_SIDECAR_OFFER_NAME) || receipt.notices.some((item) => !SHA256.test(item.sha256 ?? ""))) fail("notices must include the source offer with digests");
272
+ if (!receipt.notices.some((item) => /LGPL/i.test(item.name))) fail("notices must include the LGPL text");
273
+ if (expected.variant && receipt.variant !== expected.variant) fail(`variant ${receipt.variant} is not ${expected.variant}`);
274
+ if (expected.os && receipt.os !== expected.os) fail(`os ${receipt.os} is not ${expected.os}`);
275
+ if (expected.tools && JSON.stringify([...expected.tools].sort()) !== JSON.stringify(receipt.files.map((file) => file.tool).sort())) fail("tools differ from the configuration");
276
+ return receipt;
277
+ }
278
+
279
+ /** macOS: the nested signing receipt must show every sidecar signed (strict-valid) with the digest now on disk. */
280
+ export function macSidecarSigningProblems(nestedReceipt, signedFiles) {
281
+ const problems = [];
282
+ const byFile = new Map((nestedReceipt?.files ?? []).map((item) => [path.resolve(item.file), item]));
283
+ for (const { file, sha256 } of signedFiles) {
284
+ const item = byFile.get(path.resolve(file));
285
+ if (!item) problems.push(`${file}: not in the nested signing receipt`);
286
+ else if (item.codesign !== "strict-valid") problems.push(`${file}: codesign is ${item.codesign ?? "missing"}, not strict-valid`);
287
+ else if (item.after?.sha256 !== sha256) problems.push(`${file}: bytes changed after signing`);
288
+ }
289
+ return problems;
290
+ }
291
+
292
+ export function sidecarFileEvidence(file, { hashFile = sha256File, stat = (item) => statSync(item) } = {}) {
293
+ return { sha256: hashFile(file), sizeBytes: stat(file).size };
294
+ }
295
+
296
+ /** Windows: each sidecar's signed digest must be one the raw-EXE signing receipt recorded as Valid after signing. */
297
+ export function windowsSidecarSigningProblems(rawReceipt, receipt) {
298
+ const problems = [];
299
+ const signed = new Map((rawReceipt?.files ?? []).map((item) => [item.after?.sha256, item]));
300
+ for (const file of receipt.files) {
301
+ const item = signed.get(file.signedSha256);
302
+ if (!item) problems.push(`${file.tool}: signed digest ${file.signedSha256} is not in the raw-EXE signing receipt`);
303
+ else if (item.authenticode !== "Valid" || item.timestampPresent !== true) problems.push(`${file.tool}: Authenticode evidence is not Valid with a timestamp`);
304
+ }
305
+ return problems;
306
+ }
@@ -1,50 +1,16 @@
1
1
  #!/usr/bin/env node
2
- // Draft/import/verify utilities. Signing is intentionally absent; native prompts own signing.
3
- import { readFileSync, writeFileSync, realpathSync } from "node:fs";
2
+ // `right-release ownership-approval` is retired (Orthic-Labs/rightkit#262), under the proportional-trust rule, like trust-sync.
3
+ // Ownership exceptions are plain entries in the repository's .rightkit/ownership-exceptions.json, reviewed through git:
4
+ // no drafts, signatures, Touch ID or native helper. The command is a no-op that says so and always succeeds.
5
+ import { realpathSync } from "node:fs";
4
6
  import path from "node:path";
5
7
  import { fileURLToPath } from "node:url";
6
- import { createDraft, loadTrustedExternal, policyEntryDigest, validateRecord, verifyException } from "./ownership-approval.mjs";
7
- import { SCANNER_VERSION } from "./ownership.mjs";
8
- import manifest from "./rightkit-ownership.json" with { type: "json" };
9
8
 
10
- function arg(args, name) { const i = args.indexOf(name); return i < 0 ? undefined : args[i + 1]; }
11
- function json(file) { return JSON.parse(readFileSync(path.resolve(file), "utf8")); }
12
- export function createInstalledDraft(input, { catalog = manifest, scannerVersion = SCANNER_VERSION, now = new Date() } = {}) {
13
- const draft = createDraft({ ...input, issued: now.toISOString().slice(0, 10), policyDigest: policyEntryDigest(catalog, input.fingerprint), minimumScannerVersion: scannerVersion, minimumCatalogVersion: catalog.version ?? String(catalog.schema) });
14
- const problems = validateRecord(draft);
15
- const duration = Date.parse(draft.payload.expires) - Date.parse(draft.payload.issued);
16
- if (!(duration > 0 && duration <= 90 * 86400000)) problems.push("draft expiry must be after issued and within 90 days");
17
- if (problems.length) throw new Error(problems.join("; "));
18
- return draft;
19
- }
20
- export function main(args) {
21
- if (args.includes("--draft")) {
22
- const input = json(arg(args, "--draft"));
23
- const draft = createInstalledDraft(input);
24
- const out = arg(args, "--out");
25
- const data = `${JSON.stringify(draft, null, 2)}\n`;
26
- if (out) writeFileSync(path.resolve(out), data, { flag: "wx" }); else process.stdout.write(data);
27
- return 0;
28
- }
29
- if (args.includes("--import")) {
30
- const record = json(arg(args, "--import"));
31
- const out = arg(args, "--out");
32
- if (!out) throw new Error("--import requires --out");
33
- writeFileSync(path.resolve(out), `${JSON.stringify(record, null, 2)}\n`, { flag: "wx" });
34
- return 0;
35
- }
36
- if (args.includes("--verify")) {
37
- const record = json(arg(args, "--verify"));
38
- const context = json(arg(args, "--context"));
39
- const loaded = loadTrustedExternal();
40
- const result = verifyException(record, { ...context, trust: loaded.trust });
41
- process.stdout.write(`${JSON.stringify({ ...result, pending: loaded.pending }, null, 2)}\n`);
42
- return result.ok ? 0 : 1;
43
- }
44
- throw new Error("usage: node ownership-approval-command.mjs --draft input.json [--out draft.json] | --import signed.json --out record.json | --verify signed.json --context scan.json");
9
+ export const OWNERSHIP_APPROVAL_RETIRED = "right-release ownership-approval is retired: signed ownership exceptions (draft, sign, Touch ID and native-helper paths) no longer exist. Edit .rightkit/ownership-exceptions.json directly, with plain entries {capability, package, reason, owner, date}, and review the change through git. `right-release ownership` marks a matching finding excepted and warns about stale entries.";
10
+
11
+ export function main(_args = [], { print = (line) => process.stdout.write(`${line}\n`) } = {}) {
12
+ print(`[right-release] ${OWNERSHIP_APPROVAL_RETIRED}`);
13
+ return 0;
45
14
  }
46
15
  const realOrSelf = (file) => { try { return realpathSync(file); } catch { return path.resolve(file); } };
47
- if (process.argv[1] && realOrSelf(process.argv[1]) === realOrSelf(fileURLToPath(import.meta.url))) {
48
- try { process.exitCode = main(process.argv.slice(2)); }
49
- catch (error) { console.error(`[right-release] ownership approval: ${error.message}`); process.exitCode = 1; }
50
- }
16
+ if (process.argv[1] && realOrSelf(process.argv[1]) === realOrSelf(fileURLToPath(import.meta.url))) process.exitCode = main(process.argv.slice(2));
@@ -110,7 +110,7 @@ export function occurrenceHash(finding) {
110
110
  return sha256({ v: 2, kind, fingerprint: finding.fingerprint, path: finding.file, ecosystem, name: finding.name, alias: finding.alias, section: finding.section, requirement: finding.requirement,
111
111
  features: [...new Set(finding.features)].sort(), defaultFeatures: finding.defaultFeatures, source: finding.source, resolvedVersion: finding.resolvedVersion });
112
112
  }
113
- // Schema-v2 envelopes retain source/dependency kinds; lock routes are dependency occurrences.
113
+ // Schema-v2 draft envelopes retain source/dependency kinds; lock routes are dependency occurrences.
114
114
  const exceptionOccurrenceKind = (f) => f.kind === "transitive-dependency" ? "dependency" : f.kind ?? (f.ecosystem ? "dependency" : "source");
115
115
  function nullableString(value) { return value === null || typeof value === "string" && value.length > 0; }
116
116
  function validFingerprint(value) { return typeof value === "string" && /^[a-z0-9][a-z0-9._-]{1,127}$/.test(value) && !HEX.test(value); }
@@ -415,44 +415,5 @@ function signatureProblems(record, trust) {
415
415
  }
416
416
  }
417
417
 
418
- /** Verify signature, protected enrollment, floor, and exact scan coverage. */
419
- export function verifyException(record, { repositoryIdentity, catalogDigest, policyDigest, findings, trust, today = new Date().toISOString().slice(0, 10), scannerVersion, catalogVersion } = {}) {
420
- const problems = validateRecord(record);
421
- if (problems.length) return { ok: false, problems };
422
- const r = record.payload;
423
- if (!validDate(today)) return { ok: false, problems: ["invalid verification date"] };
424
- if (r.issued > today) problems.push("approval issued date is in the future");
425
- const duration = Date.parse(`${r.expires}T00:00:00Z`) - Date.parse(`${r.issued}T00:00:00Z`);
426
- if (duration <= 0 || duration > 90 * 86400000) problems.push("approval expiry must be after issued and within 90 days");
427
- if (r.expires < today) problems.push(`approval expired ${r.expires}`);
428
- if (problems.length) return { ok: false, problems };
429
- if (!versionAtLeast(scannerVersion, r.minimumScannerVersion)) problems.push("scanner version missing, malformed, or below approval minimum");
430
- if (!versionAtLeast(catalogVersion, r.minimumCatalogVersion)) problems.push("catalog version missing, malformed, or below approval minimum");
431
- if (!trust) problems.push("pending: external ownership trust is not enrolled");
432
- else problems.push(...validateOwnershipFloor(trust, { scannerVersion, catalogVersion, catalogDigest }).problems);
433
- if (problems.length) return { ok: false, problems };
434
- if (typeof policyDigest !== "string" || !HEX.test(policyDigest) || r.policyDigest !== policyDigest) return { ok: false, problems: ["ownership fingerprint policy digest missing or changed"] };
435
- if (!Array.isArray(findings)) return { ok: false, problems: ["current findings missing or malformed"] };
436
- let current;
437
- try { current = findings.filter((f) => f.fingerprint === r.fingerprint).map((f) => ({ path: f.file, kind: exceptionOccurrenceKind(f), hash: occurrenceHash(f) })); }
438
- catch (error) { return { ok: false, problems: [error.message] }; }
439
- for (const item of r.occurrences) if (!current.some((f) => f.path === item.path && f.hash === item.hash && f.kind === item.kind)) problems.push(`baseline occurrence changed or missing: ${item.path} (${item.hash})`);
440
- if (problems.length) return { ok: false, problems };
441
- const trackingEvidence = sha256({ repositoryIdentity: r.repositoryIdentity, requestId: r.requestId, trackingRef: r.trackingRef });
442
- if (!trust.verifiedTrackingEvidence?.includes(trackingEvidence)) problems.push("tracking reference lacks protected verification evidence");
443
- let recordIdentity, currentIdentity;
444
- try { recordIdentity = canonicalRepositoryIdentity(r.repositoryIdentity); currentIdentity = canonicalRepositoryIdentity(repositoryIdentity); }
445
- catch { problems.push("invalid repository identity"); }
446
- if (recordIdentity && recordIdentity !== r.repositoryIdentity) problems.push("approval repository identity is not canonical");
447
- if (recordIdentity && currentIdentity && recordIdentity !== currentIdentity) problems.push("repository identity changed");
448
- const registration = verifyRepositoryRegistration(trust, r.repositoryIdentity, { files: (findings ?? []).map((f) => f.file) });
449
- problems.push(...registration.problems);
450
- if (record.signature === null) problems.push("unsigned approval record");
451
- else if (!problems.length) problems.push(...signatureProblems(record, trust));
452
- return { ok: problems.length === 0, problems };
453
- }
454
-
455
- export function signPayloadForNative(record, { keyId, host, signature }) {
456
- if (!record?.payload || !signature) throw new Error("native signer must return a signature over displayed approval payload");
457
- return { schema: APPROVAL_SCHEMA, payload: record.payload, signature: { keyId, host, value: signature } };
458
- }
418
+ // Exception records are plain, git-reviewed entries (Orthic-Labs/rightkit#262); verifyException and native signing are retired.
419
+ // signatureProblems stays only as the enrolled-key signature check used by key-log tests.
@@ -8,10 +8,11 @@
8
8
  { "capability": "text-to-speech", "packages": ["rightkit-tts-qwen", "rightkit-tts-qwen-sys", "rightkit-tts"], "reason": "Speech facade & its QTS inference, safe-wrapper & FFI layers" },
9
9
  { "capability": "os-credential-store", "packages": ["rightkit-credentials", "RightKitSwift"], "reason": "Cross-platform Rust & Apple-native Swift credential APIs" },
10
10
  { "capability": "desktop-shell", "packages": ["rightkit-tauri-shell", "@rightkit/tauri-shell"], "reason": "Native Tauri shell & typed renderer bridge" },
11
+ { "capability": "lifecycle-hooks", "packages": ["rightkit-hooks", "@rightkit/hooks"], "reason": "Native Claude Code/Codex hook runtime & its Node pair share one lifecycle-hooks contract" },
11
12
  { "capability": "tauri-host-adapters", "packages": ["rightkit-device", "@rightkit/tauri"], "reason": "Native license/device services & renderer command adapters" }
12
13
  ],
13
14
  "testOwners": [
14
- { "path": "crates/rightkit-qa-driver/examples/control-test-app/Cargo.toml", "package": "control-test-app", "reason": "Unpublished desktop-control test fixture" },
15
+ { "path": "crates/rightkit-qa/examples/control-test-app/Cargo.toml", "package": "control-test-app", "reason": "Unpublished desktop-control test fixture" },
15
16
  { "path": "crates/rightkit-tauri-shell/example-app/Cargo.toml", "package": "shell-example-app", "reason": "Unpublished standalone shell demonstration & test host" }
16
17
  ]
17
18
  }
@@ -56,13 +56,15 @@ export function generateOwnershipCatalog(root) {
56
56
  if (f.type === "dependency") {
57
57
  if (!["cargo", "npm", "swiftpm"].includes(f.ecosystem) || !f.name || /^(@rightkit\/|rightkit-)/.test(f.name)) throw new Error(`ownership: imprecise dependency: ${f.id}`);
58
58
  } else if (f.type === "source") {
59
- if (!f.codeOnly || !f.extensions?.length) throw new Error(`ownership: source fingerprint must be code & extension scoped: ${f.id}`);
59
+ // codeOnly: false is explicit opt-in for rules that must see string literals (auth URLs, #279).
60
+ if (typeof f.codeOnly !== "boolean" || !f.extensions?.length) throw new Error(`ownership: source fingerprint must be code & extension scoped: ${f.id}`);
60
61
  new RegExp(f.pattern);
61
62
  } else throw new Error(`ownership: unknown fingerprint type: ${f.id}`);
62
63
  // A banned dependency blocks regardless of owner qualification: the ban stands even
63
64
  // when no replacement is used. It names the authority that imposed it.
64
65
  if (f.tier === "block" && f.policy === "banned") {
65
- if (f.type !== "dependency" || !f.policyAuthority) throw new Error(`ownership: banned fingerprint needs a dependency & policyAuthority: ${f.id}`);
66
+ // A banned source rule (e.g. in-app webview sign-in, #279) is allowed: the verdict blocks on policy "banned" for any type.
67
+ if (!["dependency", "source"].includes(f.type) || !f.policyAuthority) throw new Error(`ownership: banned fingerprint needs a dependency or source rule & policyAuthority: ${f.id}`);
66
68
  } else if (f.tier === "block") {
67
69
  const published = ownershipAvailability(owner, versions).find((row) => row.state !== "staged-unverified");
68
70
  const q = f.qualification;
@@ -101,28 +101,27 @@ export async function main(args, { evidenceFactory = createOwnershipEvidence, gi
101
101
  const result = checked.result;
102
102
  if (args.includes("--audit-json")) return printAuditJson(result, print);
103
103
  // A refusal names every blocking check on stderr and in the JSON `blocked` field. Advise-tier findings never refuse.
104
- const blocked = checked.enrollmentPending || checked.ok ? undefined : blockingChecks(checked);
104
+ const blocked = checked.ok ? undefined : blockingChecks(checked);
105
105
  if (blocked) reportBlocked(blocked, stderr);
106
106
  if (args.includes("--json")) {
107
- const error = checked.enrollmentPending ? undefined : !checked.floor.ok
107
+ const error = !checked.floor.ok
108
108
  ? { type: "ownership-floor", message: checked.floor.problems.join("; ") }
109
109
  : result.coverage.length
110
110
  ? { type: "ownership-coverage", message: `${result.coverage.length} ownership coverage gap(s)` }
111
111
  : undefined;
112
- print(JSON.stringify({ ok: checked.ok, ...(checked.enrollmentPending ? { enrollmentPending: true, warning: checked.warning } : {}), ...(error ? { error } : {}), ...(blocked ? { blocked } : {}), rows: result.rows, violations: result.violations, covered: result.covered, problems: [...result.problems, ...checked.floor.problems], ...(checked.warnings ? { warnings: checked.warnings } : {}), coverage: result.coverage, metadata: result.metadata }, null, 2));
112
+ print(JSON.stringify({ ok: checked.ok, ...(checked.enrollmentPending ? { enrollmentPending: true, warning: checked.warning } : {}), ...(error ? { error } : {}), ...(blocked ? { blocked } : {}), rows: result.rows, violations: result.violations, covered: result.covered, problems: [...result.problems, ...checked.floor.problems], ...((checked.warnings?.length || result.warnings?.length) ? { warnings: [...(checked.warnings ?? []), ...(result.warnings ?? [])] } : {}), coverage: result.coverage, metadata: result.metadata }, null, 2));
113
113
  } else {
114
114
  print(`RightKit ownership: ${path.basename(app)}`);
115
115
  print(formatTable(result.rows));
116
116
  for (const v of result.violations) {
117
117
  print(` OWN COPY ${v.fingerprint} (${v.package}) ${v.file}${v.line ? `:${v.line}` : ""} ${v.text}`);
118
118
  }
119
- for (const p of result.problems) print(` ${checked.enrollmentPending ? "DIAGNOSTIC" : "EXCEPTION"} ${p}`);
119
+ for (const p of result.problems) print(` EXCEPTION ${p}`);
120
120
  print(verdictLine(checked, result, blocked));
121
- for (const warning of checked.warnings ?? []) print(` WARNING ${warning}`);
121
+ for (const warning of [...(checked.warnings ?? []), ...(result.warnings ?? [])]) print(` WARNING ${warning}`);
122
+ if (checked.enrollmentPending && checked.warning) print(` ${checked.warning}`);
122
123
  }
123
- // CI lanes may pass `--advisory-when-pending`: before approval keys are enrolled the scan asserts
124
- // nothing, so findings print as diagnostics and the step exits 0. Release lanes never pass it.
125
- if (checked.enrollmentPending && args.includes("--advisory-when-pending")) return 0;
124
+ // `--advisory-when-pending` is accepted and ignored: pending approval enrollment is a warning and never changes the exit code (#262).
126
125
  return checked.ok ? 0 : 1;
127
126
  }
128
127
 
@@ -193,7 +192,6 @@ function tierCounts(violations) {
193
192
 
194
193
  // The human verdict line. Advise-only results pass and say so; a refusal names its checks.
195
194
  function verdictLine(checked, result, blocked) {
196
- if (checked.enrollmentPending) return `[right-release] ${checked.warning}`;
197
195
  if (checked.ok) {
198
196
  const counts = tierCounts(result.violations ?? []);
199
197
  return counts.advise
@@ -1,6 +1,9 @@
1
1
  {
2
2
  "schema": 1,
3
3
  "global": [
4
+ "cargo:blake3",
5
+ "cargo:clap",
6
+ "cargo:getrandom",
4
7
  "npm:@types/node",
5
8
  "npm:typescript",
6
9
  "npm:vitest"
@@ -786,7 +789,28 @@
786
789
  },
787
790
  {
788
791
  "repositoryIdentity": "https://github.com/bogusyogi/postright",
789
- "dependencies": []
792
+ "dependencies": [
793
+ "cargo:jiff",
794
+ "cargo:serde_json",
795
+ "cargo:tauri",
796
+ "cargo:tauri-build",
797
+ "cargo:tauri-plugin-os",
798
+ "cargo:tauri-plugin-process",
799
+ "cargo:tauri-plugin-updater",
800
+ "npm:@tailwindcss/vite",
801
+ "npm:@tauri-apps/api",
802
+ "npm:@tauri-apps/cli",
803
+ "npm:@tauri-apps/plugin-os",
804
+ "npm:@tauri-apps/plugin-process",
805
+ "npm:@tauri-apps/plugin-updater",
806
+ "npm:@types/react",
807
+ "npm:@types/react-dom",
808
+ "npm:@vitejs/plugin-react",
809
+ "npm:react",
810
+ "npm:react-dom",
811
+ "npm:tailwindcss",
812
+ "npm:vite"
813
+ ]
790
814
  },
791
815
  {
792
816
  "repositoryIdentity": "https://github.com/orthic-labs/cockpit",