@norskvideo/ctl-sdk 0.1.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.
Files changed (69) hide show
  1. package/base.css +53 -0
  2. package/browser.d.ts +8 -0
  3. package/browser.js +11 -0
  4. package/capabilities-router.d.ts +9 -0
  5. package/capabilities-router.js +15 -0
  6. package/cjs-interop.d.ts +33 -0
  7. package/cjs-interop.js +61 -0
  8. package/components/ProductIframe.d.ts +37 -0
  9. package/components/ProductIframe.js +119 -0
  10. package/components/ProductTemplateBuildForm.d.ts +40 -0
  11. package/components/ProductTemplateBuildForm.js +81 -0
  12. package/components/index.d.ts +3 -0
  13. package/components/index.js +3 -0
  14. package/components/ui-primitives.d.ts +22 -0
  15. package/components/ui-primitives.js +13 -0
  16. package/dev-url.d.ts +1 -0
  17. package/dev-url.js +14 -0
  18. package/docker-runner.d.ts +20 -0
  19. package/docker-runner.js +54 -0
  20. package/fonts/Geist-LICENSE.txt +92 -0
  21. package/fonts/Geist.woff2 +0 -0
  22. package/fonts/GeistMono.woff2 +0 -0
  23. package/fonts/README.md +21 -0
  24. package/fonts/STUDIO-FONT-SYNC.md +86 -0
  25. package/index.d.ts +15 -0
  26. package/index.js +17 -0
  27. package/license-registration.d.ts +52 -0
  28. package/license-registration.js +38 -0
  29. package/license-stager.d.ts +30 -0
  30. package/license-stager.js +118 -0
  31. package/license-v2.d.ts +107 -0
  32. package/license-v2.js +205 -0
  33. package/manifest-fetch.d.ts +29 -0
  34. package/manifest-fetch.js +100 -0
  35. package/manifest-router.d.ts +11 -0
  36. package/manifest-router.js +16 -0
  37. package/manifest-schema.d.ts +113 -0
  38. package/manifest-schema.js +135 -0
  39. package/manifest-seed.d.ts +28 -0
  40. package/manifest-seed.js +40 -0
  41. package/openapi-router.d.ts +12 -0
  42. package/openapi-router.js +21 -0
  43. package/package.json +46 -0
  44. package/parsing.d.ts +9 -0
  45. package/parsing.js +83 -0
  46. package/product-error.d.ts +4 -0
  47. package/product-error.js +8 -0
  48. package/product-health-monitor.d.ts +72 -0
  49. package/product-health-monitor.js +136 -0
  50. package/product-service.d.ts +118 -0
  51. package/product-service.js +340 -0
  52. package/product-template-error.d.ts +10 -0
  53. package/product-template-error.js +14 -0
  54. package/product-template-materials.d.ts +17 -0
  55. package/product-template-materials.js +51 -0
  56. package/product-template-parsing.d.ts +14 -0
  57. package/product-template-parsing.js +66 -0
  58. package/product-template-record.d.ts +45 -0
  59. package/product-template-record.js +1 -0
  60. package/product-types.d.ts +31 -0
  61. package/product-types.js +1 -0
  62. package/proxy-middleware.d.ts +7 -0
  63. package/proxy-middleware.js +112 -0
  64. package/runtime.d.ts +2 -0
  65. package/runtime.js +21 -0
  66. package/validate.d.ts +3 -0
  67. package/validate.js +22 -0
  68. package/workflow.d.ts +60 -0
  69. package/workflow.js +57 -0
@@ -0,0 +1,92 @@
1
+ Copyright (c) 2023 Vercel, in collaboration with basement.studio
2
+
3
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
4
+ This license is copied below, and is also available with a FAQ at:
5
+ http://scripts.sil.org/OFL
6
+
7
+ -----------------------------------------------------------
8
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
9
+ -----------------------------------------------------------
10
+
11
+ PREAMBLE
12
+ The goals of the Open Font License (OFL) are to stimulate worldwide
13
+ development of collaborative font projects, to support the font creation
14
+ efforts of academic and linguistic communities, and to provide a free and
15
+ open framework in which fonts may be shared and improved in partnership
16
+ with others.
17
+
18
+ The OFL allows the licensed fonts to be used, studied, modified and
19
+ redistributed freely as long as they are not sold by themselves. The
20
+ fonts, including any derivative works, can be bundled, embedded,
21
+ redistributed and/or sold with any software provided that any reserved
22
+ names are not used by derivative works. The fonts and derivatives,
23
+ however, cannot be released under any other type of license. The
24
+ requirement for fonts to remain under this license does not apply
25
+ to any document created using the fonts or their derivatives.
26
+
27
+ DEFINITIONS
28
+ "Font Software" refers to the set of files released by the Copyright
29
+ Holder(s) under this license and clearly marked as such. This may
30
+ include source files, build scripts and documentation.
31
+
32
+ "Reserved Font Name" refers to any names specified as such after the
33
+ copyright statement(s).
34
+
35
+ "Original Version" refers to the collection of Font Software components as
36
+ distributed by the Copyright Holder(s).
37
+
38
+ "Modified Version" refers to any derivative made by adding to, deleting,
39
+ or substituting -- in part or in whole -- any of the components of the
40
+ Original Version, by changing formats or by porting the Font Software to a
41
+ new environment.
42
+
43
+ "Author" refers to any designer, engineer, programmer, technical
44
+ writer or other person who contributed to the Font Software.
45
+
46
+ PERMISSION AND CONDITIONS
47
+ Permission is hereby granted, free of charge, to any person obtaining
48
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
49
+ redistribute, and sell modified and unmodified copies of the Font
50
+ Software, subject to the following conditions:
51
+
52
+ 1) Neither the Font Software nor any of its individual components,
53
+ in Original or Modified Versions, may be sold by itself.
54
+
55
+ 2) Original or Modified Versions of the Font Software may be bundled,
56
+ redistributed and/or sold with any software, provided that each copy
57
+ contains the above copyright notice and this license. These can be
58
+ included either as stand-alone text files, human-readable headers or
59
+ in the appropriate machine-readable metadata fields within text or
60
+ binary files as long as those fields can be easily viewed by the user.
61
+
62
+ 3) No Modified Version of the Font Software may use the Reserved Font
63
+ Name(s) unless explicit written permission is granted by the corresponding
64
+ Copyright Holder. This restriction only applies to the primary font name as
65
+ presented to the users.
66
+
67
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
68
+ Software shall not be used to promote, endorse or advertise any
69
+ Modified Version, except to acknowledge the contribution(s) of the
70
+ Copyright Holder(s) and the Author(s) or with their explicit written
71
+ permission.
72
+
73
+ 5) The Font Software, modified or unmodified, in part or in whole,
74
+ must be distributed entirely under this license, and must not be
75
+ distributed under any other license. The requirement for fonts to
76
+ remain under this license does not apply to any document created
77
+ using the Font Software.
78
+
79
+ TERMINATION
80
+ This license becomes null and void if any of the above conditions are
81
+ not met.
82
+
83
+ DISCLAIMER
84
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
85
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
86
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
87
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
88
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
89
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
90
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
91
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
92
+ OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file
Binary file
@@ -0,0 +1,21 @@
1
+ # Vendored fonts
2
+
3
+ `Geist.woff2` and `GeistMono.woff2` are **Geist Sans** and **Geist Mono**
4
+ (single variable files, weights 100–900), from Vercel's `geist` package
5
+ (v1.7.2). They back `../base.css`'s two `@font-face` rules and its
6
+ `--font-sans` / `--font-mono` theme tokens.
7
+
8
+ They are vendored here — not pulled from a CDN or an npm dependency — and live
9
+ in `@product/sdk` so every product frontend gets them from one place via
10
+ `@import "@product/sdk/base.css"`, never its own copy.
11
+
12
+ **Norsk Studio must ship the byte-identical Geist files.** Studio vendors its own
13
+ copies under `workspaces/core/static/`. For them to hash-match these, both repos
14
+ must obtain the woff2 the same way (the npm-prebuilt route below) — see
15
+ [`STUDIO-FONT-SYNC.md`](./STUDIO-FONT-SYNC.md) for the cross-repo handoff. Keep
16
+ them in sync: if either bumps Geist, update the other.
17
+
18
+ To refresh: `npm pack geist`, then copy `dist/fonts/geist-sans/Geist-Variable.woff2`
19
+ → `Geist.woff2` and `dist/fonts/geist-mono/GeistMono-Variable.woff2` → `GeistMono.woff2`.
20
+
21
+ Licensed under the SIL Open Font License 1.1 — see `Geist-LICENSE.txt`.
@@ -0,0 +1,86 @@
1
+ # Font sync: norsk-ctl ↔ Norsk Studio (handoff)
2
+
3
+ Goal: **one canonical pair of Geist woff2 files, byte-identical in both repos**,
4
+ so norsk-ctl and Norsk Studio render the same glyphs — in the live UIs and in
5
+ generated doc screenshots.
6
+
7
+ ## How norsk-ctl produced its woff2 (the answer to "which method")
8
+
9
+ **Not** built from Glyphs/UFO sources, **not** subsetted, **not** converted from
10
+ TTF. norsk-ctl vendors the **prebuilt woff2 that ship inside the `geist` npm
11
+ package, pinned to `geist@1.7.2`, unmodified** (a plain file copy). Those files
12
+ are Vercel's own official prebuilt woff2 for that release.
13
+
14
+ ```
15
+ geist@1.7.2 (npm)
16
+ dist/fonts/geist-sans/Geist-Variable.woff2 → vendored as Geist.woff2
17
+ dist/fonts/geist-mono/GeistMono-Variable.woff2 → vendored as GeistMono.woff2
18
+ LICENSE.txt → vendored as Geist-LICENSE.txt
19
+ ```
20
+
21
+ Both are single **variable** files (weights 100–900, upright roman — no separate
22
+ italic axis file). Verified: the vendored files' sha256 equals the npm package's
23
+ files exactly (unaltered by the copy).
24
+
25
+ ## Canonical bytes (verify after dropping in)
26
+
27
+ | file | sha256 | size |
28
+ | ----------------- | ------------------------------------------------------------------ | ----- |
29
+ | `Geist.woff2` | `a369fcf5628ea2aa4e1b9e2ec6a5b3624e365bda588e1f0f2f12b564f728fbb8` | 69652 |
30
+ | `GeistMono.woff2` | `fba8f577f38a2bbcbe818efa6348dd58f36303a10b8737c42fefad275be563ab` | 71368 |
31
+
32
+ Your current Studio files differ (`3bc49040…` / `61cd31b3…`, ~71 KB / ~74 KB)
33
+ because they came from `vercel/geist-font@main`'s TTF run through
34
+ `npx ttf2woff2`. That's why the glyphs match but the bytes don't: a git-`main`
35
+ snapshot (moving target) plus a local TTF→woff2 conversion whose output bytes
36
+ depend on the ttf2woff2/woff2 encoder version — neither reproducible against a
37
+ tagged release.
38
+
39
+ ## Recommendation: adopt norsk-ctl's files as canonical
40
+
41
+ The npm-prebuilt route wins on reproducibility: a **pinned release tag**
42
+ (`geist@1.7.2`), Vercel's **official** encoder output, and **no local
43
+ conversion** step to drift. Please drop in the two files above (identical to
44
+ what's in norsk-ctl at `packages/sdk/src/fonts/`) and re-verify your render.
45
+ After that both repos should hash-match.
46
+
47
+ ### Reproduce it yourself (instead of copying)
48
+
49
+ ```sh
50
+ npm pack geist@1.7.2 && tar -xzf geist-1.7.2.tgz
51
+ cp package/dist/fonts/geist-sans/Geist-Variable.woff2 Geist.woff2
52
+ cp package/dist/fonts/geist-mono/GeistMono-Variable.woff2 GeistMono.woff2
53
+ cp package/LICENSE.txt Geist-LICENSE.txt
54
+ shasum -a 256 Geist.woff2 GeistMono.woff2 # must match the table above
55
+ ```
56
+
57
+ To bump Geist later: change the pinned version in **both** repos together, copy
58
+ the new prebuilt woff2 the same way, and re-hash. Don't hand-convert from TTF.
59
+
60
+ ## The CSS side (already aligned — for reference)
61
+
62
+ Studio's `@font-face` rules (`static/tailwind.css`) already match norsk-ctl's on
63
+ family name, weight range, and `font-display` — only the `src` path differs by
64
+ necessity, and that's fine:
65
+
66
+ - **Studio** serves the file statically, so it references an absolute path:
67
+ `src: url("/static/Geist.woff2") format("woff2-variations"), url("/static/Geist.woff2") format("woff2")`.
68
+ - **norsk-ctl** bundles via Vite, so `base.css` uses a relative path
69
+ (`url("./fonts/Geist.woff2")`) which Vite hashes into `dist/assets/`.
70
+
71
+ Same for the theme tokens — keep these identical in both:
72
+
73
+ ```css
74
+ --font-sans: "Geist", ui-sans-serif, system-ui, sans-serif;
75
+ --font-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
76
+ ```
77
+
78
+ Only the **bytes** need to become identical; the wiring already is.
79
+
80
+ ## Doc screenshots
81
+
82
+ norsk-ctl regenerates its guide screenshots with `bun run docs:generate`
83
+ (Playwright drives the real UIs, `document.fonts.ready` is awaited before each
84
+ snapshot). Once the canonical files are in place on both sides, regenerate the
85
+ Studio screenshots the equivalent way so the two products look identical
86
+ side-by-side.
package/index.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ export * from "./browser.js";
2
+ export * from "./capabilities-router.js";
3
+ export * from "./cjs-interop.js";
4
+ export * from "./dev-url.js";
5
+ export * from "./docker-runner.js";
6
+ export * from "./license-registration.js";
7
+ export * from "./license-stager.js";
8
+ export * from "./license-v2.js";
9
+ export * from "./manifest-fetch.js";
10
+ export * from "./manifest-router.js";
11
+ export * from "./openapi-router.js";
12
+ export * from "./product-health-monitor.js";
13
+ export * from "./product-service.js";
14
+ export * from "./product-template-materials.js";
15
+ export * from "./proxy-middleware.js";
package/index.js ADDED
@@ -0,0 +1,17 @@
1
+ // Full surface (Node + browser-safe). For browser-only consumers, import
2
+ // from "@norskvideo/ctl-sdk/browser".
3
+ export * from "./browser.js";
4
+ export * from "./capabilities-router.js";
5
+ export * from "./cjs-interop.js";
6
+ export * from "./dev-url.js";
7
+ export * from "./docker-runner.js";
8
+ export * from "./license-registration.js";
9
+ export * from "./license-stager.js";
10
+ export * from "./license-v2.js";
11
+ export * from "./manifest-fetch.js";
12
+ export * from "./manifest-router.js";
13
+ export * from "./openapi-router.js";
14
+ export * from "./product-health-monitor.js";
15
+ export * from "./product-service.js";
16
+ export * from "./product-template-materials.js";
17
+ export * from "./proxy-middleware.js";
@@ -0,0 +1,52 @@
1
+ import { type Result } from "@norskvideo/ctl-foundation";
2
+ /**
3
+ * Why a licence could not be staged. Owned here, with the port: the codes are
4
+ * part of the `LicenseStager` contract, so a host's store implements this union
5
+ * rather than declaring a parallel one. Literal codes, not `string` — widening
6
+ * them loses the exhaustiveness that keeps a fatal conflict from silently
7
+ * becoming a warning when a code is renamed.
8
+ */
9
+ export type LicenseStageError = {
10
+ code: "LICENSE_NOT_FOUND";
11
+ message: string;
12
+ } | {
13
+ code: "LICENSE_NAME_CONFLICT";
14
+ message: string;
15
+ } | {
16
+ code: "LICENSE_UNREADABLE";
17
+ message: string;
18
+ };
19
+ /**
20
+ * Copies a licence somewhere the daemon owns, returning the new path. Injected
21
+ * rather than imported: where to stage is a host concern (norsk-ctl stages
22
+ * under its config dir, norsk-mgr under its store dir — both via
23
+ * `makeBasenameLicenseStager`).
24
+ */
25
+ export type LicenseStager = (sourcePath: string) => Result<string, LicenseStageError>;
26
+ export interface ResolvedLicenseFile {
27
+ /** The path to record in the registry — staged when staging succeeded. */
28
+ file: string;
29
+ /** Non-fatal: registration continues, having recorded the original path. */
30
+ warning?: string;
31
+ /** Fatal: registration must not proceed. */
32
+ fatal?: string;
33
+ }
34
+ /**
35
+ * Decide which licence path a registration should record.
36
+ *
37
+ * The registry's path is read at every launch — `launch-plan` reads
38
+ * `license.file` and `override-generator` mounts it as the `license.json`
39
+ * docker secret — so recording the operator's own argument means a licence
40
+ * under /tmp survives exactly until the box reboots. Staging fixes that.
41
+ *
42
+ * Failure is not uniform, so the outcomes are not either:
43
+ *
44
+ * - **unreadable / missing** is non-fatal. The daemon runs unprivileged and the
45
+ * licence lives wherever the operator put it, so a file readable by docker
46
+ * (root, which mounts the secret) but not by us has always registered fine.
47
+ * Failing here would regress those installs; warn and record the original.
48
+ * - **a name conflict is fatal.** Two different licences claiming one staged
49
+ * filename cannot both be honoured, and silently overwriting would change the
50
+ * entitlement of every product already referencing it.
51
+ */
52
+ export declare function resolveLicenseFile(file: string, stage: LicenseStager | undefined): ResolvedLicenseFile;
@@ -0,0 +1,38 @@
1
+ import { assertNever } from "@norskvideo/ctl-foundation";
2
+ /**
3
+ * Decide which licence path a registration should record.
4
+ *
5
+ * The registry's path is read at every launch — `launch-plan` reads
6
+ * `license.file` and `override-generator` mounts it as the `license.json`
7
+ * docker secret — so recording the operator's own argument means a licence
8
+ * under /tmp survives exactly until the box reboots. Staging fixes that.
9
+ *
10
+ * Failure is not uniform, so the outcomes are not either:
11
+ *
12
+ * - **unreadable / missing** is non-fatal. The daemon runs unprivileged and the
13
+ * licence lives wherever the operator put it, so a file readable by docker
14
+ * (root, which mounts the secret) but not by us has always registered fine.
15
+ * Failing here would regress those installs; warn and record the original.
16
+ * - **a name conflict is fatal.** Two different licences claiming one staged
17
+ * filename cannot both be honoured, and silently overwriting would change the
18
+ * entitlement of every product already referencing it.
19
+ */
20
+ export function resolveLicenseFile(file, stage) {
21
+ if (!stage)
22
+ return { file };
23
+ const staged = stage(file);
24
+ if (staged.status === "ok")
25
+ return { file: staged.value };
26
+ // Switch, not an `if`: adding or renaming a code then fails to compile here
27
+ // rather than falling through to the non-fatal branch, which would quietly
28
+ // downgrade a conflict to a warning.
29
+ switch (staged.error.code) {
30
+ case "LICENSE_NAME_CONFLICT":
31
+ return { file, fatal: staged.error.message };
32
+ case "LICENSE_NOT_FOUND":
33
+ case "LICENSE_UNREADABLE":
34
+ return { file, warning: staged.error.message };
35
+ default:
36
+ return assertNever(staged.error, "unknown licence stage error");
37
+ }
38
+ }
@@ -0,0 +1,30 @@
1
+ import { type Result } from "@norskvideo/ctl-foundation";
2
+ import type { LicenseStageError, LicenseStager } from "./license-registration.js";
3
+ /** Bytes the caller already holds, with the operator's filename to key them by. */
4
+ export interface LicenseContent {
5
+ /** Only the basename steers the destination — see `destinationFor`. */
6
+ name: string;
7
+ contents: string;
8
+ }
9
+ export type LicenseContentStager = (source: LicenseContent) => Result<string, LicenseStageError>;
10
+ /**
11
+ * Basename-keyed licence staging, shared by the hosts (ctl stages under its
12
+ * config dir, mgr under its store dir). Staging exists because the recorded
13
+ * path is read at every launch — a path under /tmp survives exactly until the
14
+ * box reboots.
15
+ *
16
+ * Keyed by the operator's own filename, NOT by product — one licence can
17
+ * entitle several products, so two products sharing a licence must reference
18
+ * one staged file rather than getting a copy each.
19
+ */
20
+ export declare function makeBasenameLicenseStager(licensesDir: string): LicenseStager;
21
+ /**
22
+ * Stage bytes the caller already holds, rather than a path the daemon must
23
+ * read. The two sides of a registration do not share a filesystem identity:
24
+ * the daemon runs as its service user and cannot read a licence in the
25
+ * operator's home directory, while the CLI runs as the operator and can. So
26
+ * the CLI reads and sends the contents, and this writes the canonical copy
27
+ * into the dir the daemon already owns — neither side needing a permission it
28
+ * did not already have.
29
+ */
30
+ export declare function makeBasenameContentStager(licensesDir: string): LicenseContentStager;
@@ -0,0 +1,118 @@
1
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import { err, ok, tryResult } from "@norskvideo/ctl-foundation";
4
+ /**
5
+ * A licence is a secret, and once staged the daemon is its only reader: the
6
+ * docker daemon mounts it as a compose secret and runs as root, so it is
7
+ * unaffected by this. Operators do not read the staged copy either — they pass
8
+ * their own file and the CLI, running as them, sends the bytes.
9
+ */
10
+ const STAGED_MODE = 0o600;
11
+ /**
12
+ * Where a licence lands, and whether it needs writing at all. Shared by both
13
+ * stagers so the reuse-vs-conflict rule cannot drift between them.
14
+ *
15
+ * Same bytes already there: either a re-registration or a second product on
16
+ * the same licence. Both are reuse, not conflict.
17
+ */
18
+ function decideDest(licensesDir, name, sameBytesAtDest) {
19
+ const dest = join(licensesDir, name);
20
+ if (!existsSync(dest))
21
+ return { kind: "write", dest };
22
+ return sameBytesAtDest(dest) ? { kind: "reuse", dest } : { kind: "conflict", dest };
23
+ }
24
+ function conflictError(dest) {
25
+ return {
26
+ code: "LICENSE_NAME_CONFLICT",
27
+ message: `a different license is already staged at ${dest} — ` +
28
+ "rename the incoming file, or remove the products still using the staged one. " +
29
+ "Overwriting it would change the entitlement of every product referencing it.",
30
+ };
31
+ }
32
+ /**
33
+ * Basename-keyed licence staging, shared by the hosts (ctl stages under its
34
+ * config dir, mgr under its store dir). Staging exists because the recorded
35
+ * path is read at every launch — a path under /tmp survives exactly until the
36
+ * box reboots.
37
+ *
38
+ * Keyed by the operator's own filename, NOT by product — one licence can
39
+ * entitle several products, so two products sharing a licence must reference
40
+ * one staged file rather than getting a copy each.
41
+ */
42
+ export function makeBasenameLicenseStager(licensesDir) {
43
+ return (sourcePath) => {
44
+ if (!existsSync(sourcePath)) {
45
+ return err({ code: "LICENSE_NOT_FOUND", message: `license file not found: ${sourcePath}` });
46
+ }
47
+ // The daemon runs unprivileged and the licence lives wherever the operator
48
+ // put it, so an unreadable source is an expected outcome rather than an
49
+ // exception: a file readable by docker (root, which mounts it as a compose
50
+ // secret at launch) but not by us has always registered fine, and throwing
51
+ // here would turn that into a failed registration. The comparison reads the
52
+ // source too, so it sits inside the same guard as the copy.
53
+ return tryResult(() => {
54
+ // Only the basename may steer the destination — the source path is
55
+ // operator input, and its directories must not place the copy elsewhere.
56
+ const decision = decideDest(licensesDir, basename(sourcePath), (dest) => readFileSync(dest).equals(readFileSync(sourcePath)));
57
+ if (decision.kind === "conflict")
58
+ throw new StageConflict(decision.dest);
59
+ if (decision.kind === "reuse")
60
+ return decision.dest;
61
+ mkdirSync(licensesDir, { recursive: true });
62
+ copyFileSync(sourcePath, decision.dest);
63
+ // copyFileSync carries the source's mode across, which is whatever the
64
+ // operator's own file happened to be (often 0644).
65
+ chmodSync(decision.dest, STAGED_MODE);
66
+ return decision.dest;
67
+ }, (e) => e instanceof StageConflict
68
+ ? conflictError(e.dest)
69
+ : {
70
+ code: "LICENSE_UNREADABLE",
71
+ message: `could not stage license ${sourcePath} to ${join(licensesDir, basename(sourcePath))}: ${e instanceof Error ? e.message : String(e)}`,
72
+ });
73
+ };
74
+ }
75
+ /** Thrown to carry a conflict out of the tryResult body, where the only other
76
+ * exit is an I/O failure. Never escapes this module. */
77
+ class StageConflict extends Error {
78
+ dest;
79
+ constructor(dest) {
80
+ super("license name conflict");
81
+ this.dest = dest;
82
+ }
83
+ }
84
+ /**
85
+ * Stage bytes the caller already holds, rather than a path the daemon must
86
+ * read. The two sides of a registration do not share a filesystem identity:
87
+ * the daemon runs as its service user and cannot read a licence in the
88
+ * operator's home directory, while the CLI runs as the operator and can. So
89
+ * the CLI reads and sends the contents, and this writes the canonical copy
90
+ * into the dir the daemon already owns — neither side needing a permission it
91
+ * did not already have.
92
+ */
93
+ export function makeBasenameContentStager(licensesDir) {
94
+ return ({ name, contents }) => {
95
+ // The name crosses an API boundary. Only its basename may steer the
96
+ // destination, or a caller could write anywhere the daemon can.
97
+ const safeName = basename(name);
98
+ if (safeName === "" || safeName === "." || safeName === "..") {
99
+ return err({ code: "LICENSE_NOT_FOUND", message: `not a usable license filename: '${name}'` });
100
+ }
101
+ const decision = decideDest(licensesDir, safeName, (dest) => readFileSync(dest, "utf-8") === contents);
102
+ if (decision.kind === "conflict")
103
+ return err(conflictError(decision.dest));
104
+ if (decision.kind === "reuse")
105
+ return ok(decision.dest);
106
+ return tryResult(() => {
107
+ mkdirSync(licensesDir, { recursive: true });
108
+ writeFileSync(decision.dest, contents, { encoding: "utf-8", mode: STAGED_MODE });
109
+ // mode above applies on creation only; an earlier daemon may have left
110
+ // a looser one behind on a file we are now overwriting.
111
+ chmodSync(decision.dest, STAGED_MODE);
112
+ return decision.dest;
113
+ }, (e) => ({
114
+ code: "LICENSE_UNREADABLE",
115
+ message: `could not stage license to ${decision.dest}: ${e instanceof Error ? e.message : String(e)}`,
116
+ }));
117
+ };
118
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Norsk Licensing V2 (per-product licenses).
3
+ *
4
+ * A V2 license file is a detached-signature envelope
5
+ * `{format, payload: base64, signature: base64}` where the signature is
6
+ * RSASSA-PKCS1-v1.5/SHA-256 by the Norsk root key over the exact payload
7
+ * bytes. The payload grants a list of per-product entitlements
8
+ * (product name + imageRef + versionConstraint + expiry).
9
+ *
10
+ * Anything that is NOT a V2 envelope — today's V1 license JSON, the
11
+ * "aws"/"gcp" marketplace sentinels, license archives — classifies as
12
+ * `legacy`. Classification is not acceptance: registration rejects `legacy`
13
+ * (see `notV2EnvelopeMessage`), while other readers — the CI expiry
14
+ * classifier, for one — still legitimately read V1 files. The kind survives
15
+ * precisely so "not an envelope at all" stays distinguishable from "envelope
16
+ * that failed verification"; the two send an operator to different remedies.
17
+ *
18
+ * One trust anchor is embedded: the production root key. A license may
19
+ * self-declare `nonProduction: true` (still production-signed) which warrants a
20
+ * "not for production" warning — it is not a separate trust anchor.
21
+ */
22
+ export declare const LICENSE_V2_FORMAT = "norsk-license-v2";
23
+ /**
24
+ * Wording for a readable file that is not a V2 envelope. Split rather than
25
+ * frozen because each door that rejects one needs its own middle clause while
26
+ * the diagnosis and the remedy must not drift between them: `add` says "at
27
+ * registration" (which also tells the operator their existing registrations
28
+ * still launch), renewal will say something else.
29
+ *
30
+ * Leads with "not a V2 envelope" deliberately — the same message fires for a
31
+ * path typo pointing at an HTML page or a truncated download, and those must
32
+ * not be misdiagnosed as "you have a V1 license".
33
+ */
34
+ export declare function notV2EnvelopeMessage(doorClause: string): string;
35
+ export type SignedEnvelope = {
36
+ format: string;
37
+ payload: string;
38
+ signature: string;
39
+ };
40
+ export type ProductEntitlement = {
41
+ product: string;
42
+ imageRef: string;
43
+ versionConstraint?: string;
44
+ expiresAt?: string;
45
+ featurePacks?: string[];
46
+ mode?: "signed" | "designer";
47
+ };
48
+ export type LicenseV2Payload = {
49
+ version: 2;
50
+ licenseId: string;
51
+ issuedAt: string;
52
+ expiry?: string;
53
+ issuedTo: string;
54
+ email: string;
55
+ timeout?: number;
56
+ nonProduction?: boolean;
57
+ products: ProductEntitlement[];
58
+ revocations?: {
59
+ minIssuedAt?: string;
60
+ blockedSerials?: string[];
61
+ };
62
+ };
63
+ export type ParsedLicense = {
64
+ kind: "v2";
65
+ license: LicenseV2Payload;
66
+ } | {
67
+ kind: "legacy";
68
+ } | {
69
+ kind: "invalid-v2";
70
+ reason: string;
71
+ };
72
+ export declare function parseLicenseContents(contents: string): ParsedLicense;
73
+ /** How a product entry's image resolves, for previewing before an add. The
74
+ * licence carries a repo + versionConstraint but no tag: an exact version or
75
+ * sha256 digest is fixed (not editable); a caret range / * / no constraint
76
+ * defaults to `latest` and the tag is free to edit (a caret still has to stay
77
+ * in band, enforced at add time). */
78
+ export declare function describeProductImage(entry: ProductEntitlement): {
79
+ image: string;
80
+ tag: string;
81
+ editable: boolean;
82
+ };
83
+ /** Split `repo[:tag][@digest]`; a ':' only separates the tag when it appears
84
+ * after the last '/' (registry hosts can carry ports). */
85
+ export declare function parseImageRef(ref: string): {
86
+ repo: string;
87
+ tag?: string;
88
+ digest?: string;
89
+ };
90
+ /** Same semantics as the engine's matcher: `*` (or absent) accepts any tag;
91
+ * `sha256:...` pins the digest; `^x[.y[.z]]` is a caret semver range over
92
+ * the tag; anything else is an exact tag match. Unparseable tags against a
93
+ * semver constraint fail closed. */
94
+ export declare function imageRefMatches(entry: ProductEntitlement, presented: string): boolean;
95
+ export type EntitlementCheck = {
96
+ ok: true;
97
+ entry: ProductEntitlement;
98
+ warnings: string[];
99
+ } | {
100
+ ok: false;
101
+ reason: string;
102
+ };
103
+ /** Does this V2 license entitle `productName`, optionally pinned to the
104
+ * container image being registered? */
105
+ export declare function checkProductEntitlement(parsed: {
106
+ license: LicenseV2Payload;
107
+ }, productName: string, imageRef: string | undefined, now?: Date): EntitlementCheck;