@ninedeploy/schemas 0.10.6 → 0.16.1

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 (113) hide show
  1. package/LICENSE +21 -21
  2. package/dist/accessGrants.d.ts +156 -0
  3. package/dist/accessGrants.d.ts.map +1 -0
  4. package/dist/accessGrants.js +100 -0
  5. package/dist/accessGrants.js.map +1 -0
  6. package/dist/agentCommand.d.ts.map +1 -1
  7. package/dist/agentCommand.js +11 -1
  8. package/dist/agentCommand.js.map +1 -1
  9. package/dist/auth.d.ts +53 -0
  10. package/dist/auth.d.ts.map +1 -1
  11. package/dist/auth.js +26 -0
  12. package/dist/auth.js.map +1 -1
  13. package/dist/backupPolicy.d.ts +37 -0
  14. package/dist/backupPolicy.d.ts.map +1 -0
  15. package/dist/backupPolicy.js +50 -0
  16. package/dist/backupPolicy.js.map +1 -0
  17. package/dist/common.d.ts +21 -5
  18. package/dist/common.d.ts.map +1 -1
  19. package/dist/common.js +35 -6
  20. package/dist/common.js.map +1 -1
  21. package/dist/databaseImport.d.ts +145 -0
  22. package/dist/databaseImport.d.ts.map +1 -0
  23. package/dist/databaseImport.js +115 -0
  24. package/dist/databaseImport.js.map +1 -0
  25. package/dist/doctor.d.ts +16 -0
  26. package/dist/doctor.d.ts.map +1 -1
  27. package/dist/doctor.js +25 -0
  28. package/dist/doctor.js.map +1 -1
  29. package/dist/githubApp.d.ts +236 -0
  30. package/dist/githubApp.d.ts.map +1 -0
  31. package/dist/githubApp.js +223 -0
  32. package/dist/githubApp.js.map +1 -0
  33. package/dist/index.d.ts +11 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +11 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/kernel.d.ts +5 -2
  38. package/dist/kernel.d.ts.map +1 -1
  39. package/dist/kernel.js +11 -2
  40. package/dist/kernel.js.map +1 -1
  41. package/dist/management.d.ts +46 -2
  42. package/dist/management.d.ts.map +1 -1
  43. package/dist/management.js +68 -15
  44. package/dist/management.js.map +1 -1
  45. package/dist/multiNode.d.ts +326 -0
  46. package/dist/multiNode.d.ts.map +1 -0
  47. package/dist/multiNode.js +296 -0
  48. package/dist/multiNode.js.map +1 -0
  49. package/dist/ninedeployManifest.d.ts.map +1 -1
  50. package/dist/ninedeployManifest.js +6 -3
  51. package/dist/ninedeployManifest.js.map +1 -1
  52. package/dist/panelBackup.d.ts +73 -0
  53. package/dist/panelBackup.d.ts.map +1 -0
  54. package/dist/panelBackup.js +45 -0
  55. package/dist/panelBackup.js.map +1 -0
  56. package/dist/proxyConfig.d.ts +132 -0
  57. package/dist/proxyConfig.d.ts.map +1 -0
  58. package/dist/proxyConfig.js +105 -0
  59. package/dist/proxyConfig.js.map +1 -0
  60. package/dist/publicAccess.d.ts +64 -0
  61. package/dist/publicAccess.d.ts.map +1 -0
  62. package/dist/publicAccess.js +63 -0
  63. package/dist/publicAccess.js.map +1 -0
  64. package/dist/secretProviders.d.ts +119 -0
  65. package/dist/secretProviders.d.ts.map +1 -0
  66. package/dist/secretProviders.js +130 -0
  67. package/dist/secretProviders.js.map +1 -0
  68. package/dist/secretScan.js +1 -1
  69. package/dist/service.d.ts +235 -17
  70. package/dist/service.d.ts.map +1 -1
  71. package/dist/service.js +331 -26
  72. package/dist/service.js.map +1 -1
  73. package/dist/terminals.d.ts +284 -0
  74. package/dist/terminals.d.ts.map +1 -0
  75. package/dist/terminals.js +177 -0
  76. package/dist/terminals.js.map +1 -0
  77. package/dist/traffic.d.ts +276 -0
  78. package/dist/traffic.d.ts.map +1 -0
  79. package/dist/traffic.js +103 -0
  80. package/dist/traffic.js.map +1 -0
  81. package/dist/workspaces.d.ts +19 -1
  82. package/dist/workspaces.d.ts.map +1 -1
  83. package/dist/workspaces.js +26 -0
  84. package/dist/workspaces.js.map +1 -1
  85. package/package.json +5 -4
  86. package/src/accessGrants.ts +117 -0
  87. package/src/agentCommand.ts +67 -0
  88. package/src/ai.ts +32 -0
  89. package/src/auth.ts +323 -0
  90. package/src/backupPolicy.ts +55 -0
  91. package/src/common.ts +179 -0
  92. package/src/databaseImport.ts +138 -0
  93. package/src/doctor.ts +161 -0
  94. package/src/environments.ts +23 -0
  95. package/src/githubApp.ts +268 -0
  96. package/src/index.ts +30 -0
  97. package/src/insights.ts +78 -0
  98. package/src/kernel.ts +180 -0
  99. package/src/labels.ts +86 -0
  100. package/src/management.ts +620 -0
  101. package/src/multiNode.ts +356 -0
  102. package/src/ninedeployManifest.ts +363 -0
  103. package/src/panelBackup.ts +90 -0
  104. package/src/project.ts +42 -0
  105. package/src/proxyConfig.ts +139 -0
  106. package/src/publicAccess.ts +80 -0
  107. package/src/runtimeVersions.ts +232 -0
  108. package/src/secretProviders.ts +159 -0
  109. package/src/secretScan.ts +169 -0
  110. package/src/service.ts +1310 -0
  111. package/src/terminals.ts +212 -0
  112. package/src/traffic.ts +130 -0
  113. package/src/workspaces.ts +215 -0
@@ -0,0 +1,80 @@
1
+ import { z } from 'zod';
2
+
3
+ // ── Public database access (0.14) ──────────────────────────────────────────
4
+ // Request and response shapes for `/v1/databases/:id/public-access`. A
5
+ // per-database Traefik TCP sidecar (`nd-dbpub-<slug>`) publishes one host
6
+ // port, gated by a required IP allow-list. These are SHAPE checks only: the
7
+ // server validates every allow-list entry with `node:net` (and refuses `/0`),
8
+ // checks the port against reserved and in-use host ports, and refuses engines
9
+ // and TLS modes the sidecar cannot serve. Design: DESIGN.md §1.
10
+
11
+ export const PUBLIC_ACCESS_PORT_MIN = 1024;
12
+ export const PUBLIC_ACCESS_PORT_MAX = 65535;
13
+ export const PUBLIC_ACCESS_ALLOWLIST_MAX = 100;
14
+
15
+ /** Engines a TCP sidecar can expose. Every other engine is refused with 422. */
16
+ export const publicAccessEngines = ['postgres', 'mysql', 'mariadb', 'redis', 'valkey', 'mongo', 'keydb', 'dragonfly'] as const;
17
+
18
+ /** Mirrors `databasePublicTlsMode` in `@ninedeploy/db`. */
19
+ export const publicAccessTlsMode = z.enum(['none', 'terminate']);
20
+ export type PublicAccessTlsMode = z.infer<typeof publicAccessTlsMode>;
21
+
22
+ export const publicAccessPort = z.number().int().min(PUBLIC_ACCESS_PORT_MIN).max(PUBLIC_ACCESS_PORT_MAX);
23
+
24
+ /** One allow-list entry as typed (an address or CIDR); the server parses it. */
25
+ export const publicAccessAllowlistEntry = z.string().trim().min(1).max(64);
26
+
27
+ /** 1–100 entries. An empty allow-list is never "allow everyone". */
28
+ export const publicAccessAllowlist = z.array(publicAccessAllowlistEntry).min(1).max(PUBLIC_ACCESS_ALLOWLIST_MAX);
29
+
30
+ /** The hostname clients connect to (and, in terminate mode, the TLS name). */
31
+ export const publicAccessHostname = z
32
+ .string()
33
+ .trim()
34
+ .min(1)
35
+ .max(253)
36
+ .regex(
37
+ /^(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(?:\.(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?))*$/,
38
+ 'must be a valid hostname',
39
+ );
40
+
41
+ /**
42
+ * PUT /v1/databases/:id/public-access (operator). Applies synchronously and
43
+ * answers with the GET shape. Disabling is DELETE, so `enabled` is pinned to
44
+ * `true` here.
45
+ */
46
+ export const publicAccessPut = z.object({
47
+ enabled: z.literal(true),
48
+ port: publicAccessPort,
49
+ ipAllowlist: publicAccessAllowlist,
50
+ tlsMode: publicAccessTlsMode.default('none'),
51
+ tlsHostname: publicAccessHostname.optional(),
52
+ });
53
+ export type PublicAccessPut = z.input<typeof publicAccessPut>;
54
+
55
+ /** GET (db admin) and PUT response. `configured: false` = no row. */
56
+ export const publicAccessStatus = z.object({
57
+ /** false for engines a TCP sidecar cannot expose. */
58
+ supported: z.boolean(),
59
+ configured: z.boolean(),
60
+ enabled: z.boolean(),
61
+ port: z.number().int().nullable(),
62
+ tlsMode: publicAccessTlsMode,
63
+ tlsHostname: z.string().nullable(),
64
+ ipAllowlist: z.array(z.string()),
65
+ status: z.enum(['off', 'running', 'error']),
66
+ lastError: z.string().nullable(),
67
+ appliedAt: z.string().nullable(),
68
+ /** `tlsHostname`, else the panel domain, else the host of the public URL. */
69
+ publicHost: z.string().nullable(),
70
+ });
71
+ export type PublicAccessStatus = z.infer<typeof publicAccessStatus>;
72
+
73
+ /** The `publicAccess` field added to the database serializer; null when unconfigured. */
74
+ export const databasePublicAccessSummary = z
75
+ .object({
76
+ enabled: z.boolean(),
77
+ port: z.number().int(),
78
+ })
79
+ .nullable();
80
+ export type DatabasePublicAccessSummary = z.infer<typeof databasePublicAccessSummary>;
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Curated runtime version catalog — the single source of truth for every
3
+ * language version NineDeploy suggests anywhere in the product (Manifest
4
+ * Creator presets, the CLI's `starterManifest`, the runtime version picker).
5
+ *
6
+ * Why this file exists: these versions used to be hand-written literals
7
+ * duplicated across `apps/web` and `packages/sdk`. Nobody owned them, so
8
+ * they drifted until the shipped defaults pinned runtimes that had gone
9
+ * end-of-life. Everything now derives from this one table.
10
+ *
11
+ * The catalog is *advisory*, never a constraint. `runtime.version` in the
12
+ * manifest schema stays free-form (`^\d+(?:\.\d+){0,2}$`), so an operator
13
+ * can pin any version their build needs — including an EOL one. What the
14
+ * catalog buys us is a sensible default plus an honest warning when the
15
+ * pinned version is no longer supported upstream.
16
+ *
17
+ * ── Maintaining this file ────────────────────────────────────────────────
18
+ * Bump `RUNTIME_CATALOG_REVIEWED` whenever you touch the table, and check
19
+ * it against upstream release schedules:
20
+ * Node https://nodejs.org/en/about/previous-releases
21
+ * Python https://devguide.python.org/versions/
22
+ * Go https://go.dev/doc/devel/release
23
+ * Ruby https://www.ruby-lang.org/en/downloads/branches/
24
+ * PHP https://www.php.net/supported-versions.php
25
+ * Java https://adoptium.net/support/
26
+ * Rust https://www.rust-lang.org/
27
+ */
28
+ import type { RuntimeType } from './ninedeployManifest.js';
29
+
30
+ /**
31
+ * ISO date the table below was last checked against upstream schedules.
32
+ * Surfaced in the UI so a stale catalog is visible rather than silent.
33
+ */
34
+ export const RUNTIME_CATALOG_REVIEWED = '2026-08-26';
35
+
36
+ /**
37
+ * Upstream support state of a version, ordered from healthiest to worst.
38
+ *
39
+ * - `current` — newest stable release; ahead of the recommended pin.
40
+ * - `lts` — long-term-support line; the safe production choice.
41
+ * - `maintenance` — still supported, but no longer the primary line.
42
+ * - `security` — security fixes only; no bug fixes.
43
+ * - `eol` — unsupported upstream. Usable, but nothing gets patched.
44
+ */
45
+ export type RuntimeSupport = 'current' | 'lts' | 'maintenance' | 'security' | 'eol';
46
+
47
+ export interface RuntimeVersionOption {
48
+ /** Value written into `runtime.version` — matches the manifest regex. */
49
+ version: string;
50
+ /** Human label for the picker, e.g. "24 — Active LTS". */
51
+ label: string;
52
+ support: RuntimeSupport;
53
+ /** ISO date support ends (or ended). Omitted when upstream has not set one. */
54
+ eol?: string;
55
+ }
56
+
57
+ export interface RuntimeVersionCatalogEntry {
58
+ /** Display name used in advisory messages, e.g. "Node.js". */
59
+ name: string;
60
+ /** The version presets and the "recommended" hint use. Must appear in `options`. */
61
+ recommended: string;
62
+ /** Newest first — the picker renders them in this order. */
63
+ options: readonly RuntimeVersionOption[];
64
+ }
65
+
66
+ /**
67
+ * Versions offered per runtime. Only runtimes we can meaningfully pin appear
68
+ * here; `auto` and `static` have no version axis and are absent by design.
69
+ *
70
+ * Each list keeps a couple of EOL entries on purpose: teams migrating an
71
+ * existing app need to reproduce their current runtime before they can move
72
+ * off it, and an undocumented version they have to guess at is worse than a
73
+ * documented one carrying a warning.
74
+ */
75
+ export const RUNTIME_VERSION_CATALOG: Partial<Record<RuntimeType, RuntimeVersionCatalogEntry>> = {
76
+ node: {
77
+ name: 'Node.js',
78
+ recommended: '24',
79
+ options: [
80
+ { version: '26', label: '26 — Current', support: 'current', eol: '2029-04-30' },
81
+ { version: '24', label: '24 — Active LTS', support: 'lts', eol: '2028-04-30' },
82
+ { version: '22', label: '22 — Maintenance LTS', support: 'maintenance', eol: '2027-04-30' },
83
+ { version: '20', label: '20 — end-of-life', support: 'eol', eol: '2026-04-30' },
84
+ { version: '18', label: '18 — end-of-life', support: 'eol', eol: '2025-04-30' },
85
+ ],
86
+ },
87
+ python: {
88
+ name: 'Python',
89
+ recommended: '3.14',
90
+ options: [
91
+ { version: '3.14', label: '3.14 — latest stable', support: 'current', eol: '2030-10-31' },
92
+ { version: '3.13', label: '3.13 — bugfix', support: 'maintenance', eol: '2029-10-31' },
93
+ { version: '3.12', label: '3.12 — security fixes only', support: 'security', eol: '2028-10-31' },
94
+ { version: '3.11', label: '3.11 — security fixes only', support: 'security', eol: '2027-10-31' },
95
+ ],
96
+ },
97
+ go: {
98
+ name: 'Go',
99
+ recommended: '1.27',
100
+ options: [
101
+ { version: '1.27', label: '1.27 — latest stable', support: 'current' },
102
+ { version: '1.26', label: '1.26 — supported', support: 'maintenance' },
103
+ { version: '1.25', label: '1.25 — end-of-life', support: 'eol' },
104
+ { version: '1.24', label: '1.24 — end-of-life', support: 'eol' },
105
+ ],
106
+ },
107
+ ruby: {
108
+ name: 'Ruby',
109
+ recommended: '3.4',
110
+ options: [
111
+ { version: '4.0', label: '4.0 — latest stable', support: 'current' },
112
+ { version: '3.4', label: '3.4 — normal maintenance', support: 'maintenance' },
113
+ { version: '3.3', label: '3.3 — security fixes only', support: 'security', eol: '2027-03-31' },
114
+ { version: '3.2', label: '3.2 — end-of-life', support: 'eol', eol: '2026-04-01' },
115
+ ],
116
+ },
117
+ php: {
118
+ name: 'PHP',
119
+ recommended: '8.4',
120
+ options: [
121
+ { version: '8.5', label: '8.5 — active support', support: 'current', eol: '2029-12-31' },
122
+ { version: '8.4', label: '8.4 — active support', support: 'maintenance', eol: '2028-12-31' },
123
+ { version: '8.3', label: '8.3 — security fixes only', support: 'security', eol: '2027-12-31' },
124
+ { version: '8.2', label: '8.2 — security fixes only', support: 'security', eol: '2026-12-31' },
125
+ { version: '8.1', label: '8.1 — end-of-life', support: 'eol', eol: '2025-12-31' },
126
+ ],
127
+ },
128
+ java: {
129
+ name: 'Java',
130
+ recommended: '25',
131
+ options: [
132
+ { version: '26', label: '26 — latest feature release (non-LTS)', support: 'current' },
133
+ { version: '25', label: '25 — LTS', support: 'lts', eol: '2031-09-30' },
134
+ { version: '21', label: '21 — LTS', support: 'lts', eol: '2029-12-31' },
135
+ { version: '17', label: '17 — LTS', support: 'maintenance', eol: '2027-10-31' },
136
+ ],
137
+ },
138
+ rust: {
139
+ name: 'Rust',
140
+ // Rust ships a new stable every six weeks and backports nothing, so any
141
+ // version below the newest stable is unsupported by definition. Expect
142
+ // this list to need a bump more often than the others.
143
+ recommended: '1.98',
144
+ options: [
145
+ { version: '1.98', label: '1.98 — latest stable', support: 'current' },
146
+ { version: '1.97', label: '1.97 — superseded', support: 'eol' },
147
+ { version: '1.96', label: '1.96 — superseded', support: 'eol' },
148
+ { version: '1.95', label: '1.95 — superseded', support: 'eol' },
149
+ ],
150
+ },
151
+ };
152
+
153
+ /** Versions offered for a runtime type. Empty for types with no version axis. */
154
+ export function runtimeVersionOptions(type: RuntimeType): readonly RuntimeVersionOption[] {
155
+ return RUNTIME_VERSION_CATALOG[type]?.options ?? [];
156
+ }
157
+
158
+ /**
159
+ * The version presets pin for a runtime, or `undefined` when the type has no
160
+ * version axis (`auto`, `static`) — in which case the manifest should omit
161
+ * `runtime.version` entirely and let Nixpacks decide.
162
+ */
163
+ export function recommendedRuntimeVersion(type: RuntimeType): string | undefined {
164
+ return RUNTIME_VERSION_CATALOG[type]?.recommended;
165
+ }
166
+
167
+ /**
168
+ * Resolve an operator-typed version to its catalog entry.
169
+ *
170
+ * Matching is longest-prefix on dot boundaries, so `20.18.1` resolves to
171
+ * Node's `20` entry and `3.12.4` to Python's `3.12` — pinning a patch
172
+ * release still yields the right support status. The dot boundary is what
173
+ * keeps `2` from matching `20`.
174
+ */
175
+ export function findRuntimeVersion(
176
+ type: RuntimeType,
177
+ version: string,
178
+ ): RuntimeVersionOption | undefined {
179
+ return [...runtimeVersionOptions(type)]
180
+ .sort((a, b) => b.version.length - a.version.length)
181
+ .find((o) => version === o.version || version.startsWith(`${o.version}.`));
182
+ }
183
+
184
+ /** A note to show next to the version field. `error` is not a blocker — only a warning. */
185
+ export interface RuntimeVersionAdvisory {
186
+ level: 'error' | 'warn' | 'info';
187
+ message: string;
188
+ }
189
+
190
+ /**
191
+ * Describe the health of a pinned version, or `null` when there is nothing
192
+ * worth saying (supported version, no version pinned, runtime with no
193
+ * version axis).
194
+ *
195
+ * Deliberately never rejects: an EOL pin is a warning, not an error, because
196
+ * reproducing a legacy runtime is a legitimate reason to deploy one.
197
+ */
198
+ export function runtimeVersionAdvisory(
199
+ type: RuntimeType,
200
+ version: string | undefined,
201
+ ): RuntimeVersionAdvisory | null {
202
+ const entry = RUNTIME_VERSION_CATALOG[type];
203
+ if (!entry || !version) return null;
204
+
205
+ const match = findRuntimeVersion(type, version);
206
+ if (!match) {
207
+ return {
208
+ level: 'info',
209
+ message: `${entry.name} ${version} is not in NineDeploy's catalog (last reviewed ${RUNTIME_CATALOG_REVIEWED}). The build will still try it — Nixpacks resolves the version itself.`,
210
+ };
211
+ }
212
+
213
+ // One shared clause for both levels: upstream dates are a label, not a
214
+ // tense, so the same wording works whether the date is past or future.
215
+ const support = match.eol ? ` Upstream support: ${match.eol}.` : '';
216
+
217
+ if (match.support === 'eol') {
218
+ return {
219
+ level: 'error',
220
+ message: `${entry.name} ${match.version} is end-of-life and no longer receives security patches. You can still deploy it — ${entry.recommended} is the recommended pin.${support}`,
221
+ };
222
+ }
223
+
224
+ if (match.support === 'security') {
225
+ return {
226
+ level: 'warn',
227
+ message: `${entry.name} ${match.version} receives security fixes only — no bug fixes. Recommended pin: ${entry.recommended}.${support}`,
228
+ };
229
+ }
230
+
231
+ return null;
232
+ }
@@ -0,0 +1,159 @@
1
+ import { z } from 'zod';
2
+ import { providerBaseUrl } from './common.js';
3
+
4
+ // ── Secret managers (0.14) ─────────────────────────────────────────────────
5
+ // Request and response shapes for `/v1/settings/secret-providers`. HashiCorp
6
+ // Vault / OpenBao (KV v2) and AWS Secrets Manager run alongside the existing
7
+ // settings-based Infisical / Doppler provider, which is untouched. Credentials
8
+ // only ever appear in REQUEST shapes; on update an omitted credential field
9
+ // keeps the stored value. The server enforces https (http only with private
10
+ // egress) and proves the settings with a test call. Design: DESIGN.md §4.
11
+
12
+ /** Mirrors `secretProviderKind` in `@ninedeploy/db`. */
13
+ export const secretProviderKind = z.enum(['vault', 'aws']);
14
+ export type SecretProviderKind = z.infer<typeof secretProviderKind>;
15
+
16
+ // ── Reference grammar pieces (DESIGN §4.1) ─────────────────────────────────
17
+
18
+ /**
19
+ * A Vault path relative to the KV v2 mount: segments of `[A-Za-z0-9_.-]+`
20
+ * joined by `/`, no leading or trailing `/`, and no `.` or `..` segment.
21
+ */
22
+ export const VAULT_PATH_RE = /^(?!\.\.?(?:\/|$))[A-Za-z0-9_.-]+(?:\/(?!\.\.?(?:\/|$))[A-Za-z0-9_.-]+)*$/;
23
+ /** A KV v2 field name. */
24
+ export const VAULT_FIELD_RE = /^[A-Za-z0-9_.-]+$/;
25
+ /** An AWS secret name or full ARN. */
26
+ export const AWS_SECRET_ID_RE = /^[A-Za-z0-9/_+=.@:-]{1,2048}$/;
27
+ export const AWS_REGION_RE = /^[a-z]{2}(-gov)?-[a-z]+-\d$/;
28
+ export const AWS_ACCESS_KEY_ID_RE = /^(AKIA|ASIA)[A-Z0-9]{16}$/;
29
+
30
+ export const vaultPath = z.string().max(1024).regex(VAULT_PATH_RE, 'invalid Vault path');
31
+ export const awsSecretId = z.string().regex(AWS_SECRET_ID_RE, 'invalid AWS secret id');
32
+
33
+ // ── HashiCorp Vault / OpenBao ──────────────────────────────────────────────
34
+
35
+ export const vaultAuthMethod = z.enum(['token', 'approle']);
36
+ export type VaultAuthMethod = z.infer<typeof vaultAuthMethod>;
37
+
38
+ export const vaultProviderConfig = z
39
+ .object({
40
+ address: providerBaseUrl,
41
+ /** Vault Enterprise / OpenBao namespace (`X-Vault-Namespace`). */
42
+ namespace: vaultPath.optional(),
43
+ mount: vaultPath.default('secret'),
44
+ authMethod: vaultAuthMethod,
45
+ approleMount: vaultPath.default('approle'),
46
+ })
47
+ .strict();
48
+ export type VaultProviderConfig = z.input<typeof vaultProviderConfig>;
49
+
50
+ const vaultSecret = z.string().min(1).max(4096);
51
+
52
+ export const vaultProviderCredentials = z
53
+ .object({
54
+ token: vaultSecret.optional(),
55
+ roleId: vaultSecret.optional(),
56
+ secretId: vaultSecret.optional(),
57
+ })
58
+ .strict();
59
+ export type VaultProviderCredentials = z.infer<typeof vaultProviderCredentials>;
60
+
61
+ /** PUT /v1/settings/secret-providers/vault. */
62
+ export const vaultProviderPut = z
63
+ .object({
64
+ enabled: z.boolean().optional(),
65
+ config: vaultProviderConfig,
66
+ credentials: vaultProviderCredentials.optional(),
67
+ })
68
+ .strict()
69
+ .refine((v) => v.config.authMethod !== 'token' || (v.credentials?.roleId === undefined && v.credentials?.secretId === undefined), {
70
+ message: 'roleId and secretId belong to authMethod "approle"',
71
+ path: ['credentials'],
72
+ })
73
+ .refine((v) => v.config.authMethod !== 'approle' || v.credentials?.token === undefined, {
74
+ message: 'token belongs to authMethod "token"',
75
+ path: ['credentials', 'token'],
76
+ });
77
+ export type VaultProviderPut = z.input<typeof vaultProviderPut>;
78
+
79
+ // ── AWS Secrets Manager ────────────────────────────────────────────────────
80
+
81
+ export const awsProviderConfig = z
82
+ .object({
83
+ region: z.string().regex(AWS_REGION_RE, 'invalid AWS region'),
84
+ /** VPC endpoint or compatible service; https. */
85
+ endpoint: providerBaseUrl.optional(),
86
+ roleArn: z
87
+ .string()
88
+ .max(2048)
89
+ .regex(/^arn:aws[a-z-]*:iam::\d{12}:role\/[\w+=,.@/-]{1,512}$/, 'invalid IAM role ARN')
90
+ .optional(),
91
+ externalId: z
92
+ .string()
93
+ .min(2)
94
+ .max(1224)
95
+ .regex(/^[\w+=,.@:/-]+$/, 'invalid external id')
96
+ .optional(),
97
+ roleSessionName: z
98
+ .string()
99
+ .regex(/^[\w+=,.@-]{2,64}$/, 'invalid role session name')
100
+ .default('ninedeploy'),
101
+ })
102
+ .strict()
103
+ .refine((v) => v.externalId === undefined || v.roleArn !== undefined, {
104
+ message: 'externalId needs roleArn',
105
+ path: ['externalId'],
106
+ });
107
+ export type AwsProviderConfig = z.input<typeof awsProviderConfig>;
108
+
109
+ export const awsProviderCredentials = z
110
+ .object({
111
+ accessKeyId: z.string().regex(AWS_ACCESS_KEY_ID_RE, 'invalid access key id').optional(),
112
+ secretAccessKey: z.string().min(1).max(256).optional(),
113
+ sessionToken: z.string().min(1).max(4096).optional(),
114
+ })
115
+ .strict();
116
+ export type AwsProviderCredentials = z.infer<typeof awsProviderCredentials>;
117
+
118
+ /** PUT /v1/settings/secret-providers/aws. */
119
+ export const awsProviderPut = z
120
+ .object({
121
+ enabled: z.boolean().optional(),
122
+ config: awsProviderConfig,
123
+ credentials: awsProviderCredentials.optional(),
124
+ })
125
+ .strict();
126
+ export type AwsProviderPut = z.input<typeof awsProviderPut>;
127
+
128
+ // ── Test and views ─────────────────────────────────────────────────────────
129
+
130
+ /** POST /v1/settings/secret-providers/:kind/test. Both probes are optional. */
131
+ export const secretProviderTestRequest = z
132
+ .object({
133
+ /** vault: also read this path (relative to the mount). */
134
+ probePath: vaultPath.optional(),
135
+ /** aws: also read this secret. */
136
+ probeSecretId: awsSecretId.optional(),
137
+ })
138
+ .strict();
139
+ export type SecretProviderTestRequest = z.infer<typeof secretProviderTestRequest>;
140
+
141
+ export const secretProviderTestResult = z.object({
142
+ ok: z.boolean(),
143
+ /** Redacted; response bodies cut to 200 characters. */
144
+ detail: z.string(),
145
+ });
146
+ export type SecretProviderTestResult = z.infer<typeof secretProviderTestResult>;
147
+
148
+ /** One element of GET /v1/settings/secret-providers. Never carries a credential. */
149
+ export const secretProviderView = z.object({
150
+ kind: secretProviderKind,
151
+ configured: z.boolean(),
152
+ enabled: z.boolean(),
153
+ /** Non-secret settings; `{}` when unconfigured. */
154
+ config: z.record(z.string(), z.unknown()),
155
+ hasCredential: z.boolean(),
156
+ lastTestedAt: z.string().nullable(),
157
+ lastTestError: z.string().nullable(),
158
+ });
159
+ export type SecretProviderView = z.infer<typeof secretProviderView>;
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Secret-pattern scanner for `.ninedeploy` manifests.
3
+ *
4
+ * The manifest is committed to the repo, so it must NEVER carry real
5
+ * credentials, tokens or connection strings. This module is a defensive net:
6
+ * patterns are matched against the raw text, and any hit fails the load.
7
+ *
8
+ * The list is intentionally explicit (named patterns) rather than a generic
9
+ * entropy heuristic — false positives are user-hostile (legitimate values
10
+ * that look credential-like should pass). Every regex is also bounded to
11
+ * avoid runaway matching on long inputs.
12
+ *
13
+ * Lives in `@ninedeploy/schemas` (not in `@ninedeploy/server`) because the
14
+ * CLI also needs to run the scan in `ninedeploy manifest validate`. Anything
15
+ * that loads a manifest — server, CLI, future MCP tool — should reuse this.
16
+ */
17
+
18
+ /** A single rule: a human-readable name plus a regex that MUST match the
19
+ * *beginning* of a credential — anchoring left keeps the regex from firing
20
+ * on substrings inside longer strings. */
21
+ export interface SecretPattern {
22
+ readonly id: string;
23
+ readonly description: string;
24
+ readonly regex: RegExp;
25
+ }
26
+
27
+ export const SECRET_PATTERNS: ReadonlyArray<SecretPattern> = [
28
+ {
29
+ id: 'aws-access-key',
30
+ description: 'AWS access key id (AKIA…)',
31
+ regex: /AKIA[0-9A-Z]{16}\b/,
32
+ },
33
+ {
34
+ id: 'github-pat-classic',
35
+ description: 'GitHub personal access token (ghp_…)',
36
+ regex: /\bghp_[A-Za-z0-9]{36}\b/,
37
+ },
38
+ {
39
+ id: 'github-pat-fine-grained',
40
+ description: 'GitHub fine-grained PAT (github_pat_…)',
41
+ regex: /\bgithub_pat_[A-Za-z0-9_]{82}\b/,
42
+ },
43
+ {
44
+ id: 'gitlab-pat',
45
+ description: 'GitLab personal access token (glpat-…)',
46
+ regex: /\bglpat-[A-Za-z0-9_-]{20,}\b/,
47
+ },
48
+ {
49
+ id: 'slack-token',
50
+ description: 'Slack token (xox[baprs]-…)',
51
+ regex: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/,
52
+ },
53
+ {
54
+ id: 'stripe-live-secret',
55
+ description: 'Stripe live secret key (sk_live_…)',
56
+ regex: /\bsk_live_[A-Za-z0-9]{24,}\b/,
57
+ },
58
+ {
59
+ id: 'stripe-live-restricted',
60
+ description: 'Stripe live restricted key (rk_live_…)',
61
+ regex: /\brk_live_[A-Za-z0-9]{24,}\b/,
62
+ },
63
+ {
64
+ id: 'openai-secret',
65
+ description: 'OpenAI API key (sk-… / sk-proj-…)',
66
+ // `(?!ant-)` keeps Anthropic keys single-matched under the anthropic-key
67
+ // rule; `proj-` is the current project-key prefix whose character set
68
+ // includes `-`/`_`.
69
+ regex: /\bsk-(?!ant-)(?:proj-)?[A-Za-z0-9_-]{40,}\b/,
70
+ },
71
+ {
72
+ id: 'anthropic-key',
73
+ description: 'Anthropic API key (sk-ant-…)',
74
+ regex: /\bsk-ant-[A-Za-z0-9_-]{40,}\b/,
75
+ },
76
+ {
77
+ id: 'discord-webhook',
78
+ description: 'Discord webhook URL (discord…/api/webhooks/…)',
79
+ regex: /https?:\/\/(?:[A-Za-z0-9-]+\.)?discord(?:app)?\.com\/api\/webhooks\/\d+\/[A-Za-z0-9_-]+/,
80
+ },
81
+ {
82
+ id: 'database-url-credentials',
83
+ description: 'Database URL with embedded credentials (scheme://user:pass@…)',
84
+ // The password class accepts either non-special chars or `%XX` percent-
85
+ // encoded triplets: a literal '@' in the password cannot appear (it is
86
+ // the user/host delimiter), but the URL-encoded form `%40` can — and
87
+ // tools that build connection strings from user input routinely emit
88
+ // `%40` instead of escaping it themselves. The `{3,}` minimum is
89
+ // measured in raw characters (not encoded triplets) to keep the
90
+ // "obviously not a secret" guardrail a real password still satisfies.
91
+ regex: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqps?|mssql):\/\/[^\s:@'"]+:(?:[^\s@'"]|%[0-9A-Fa-f]{2}){3,}@/i,
92
+ },
93
+ {
94
+ id: 'private-key',
95
+ description: 'PEM private key block',
96
+ regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
97
+ },
98
+ {
99
+ id: 'jwt-bearer',
100
+ description: 'Bearer JWT in a literal value',
101
+ regex: /\bBearer\s+eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/,
102
+ },
103
+ ];
104
+
105
+ /** A single secret-pattern hit in scanned text. */
106
+ export interface SecretHit {
107
+ patternId: string;
108
+ description: string;
109
+ /** Index in the original text where the match starts. */
110
+ start: number;
111
+ /** Index in the original text where the match ends. */
112
+ end: number;
113
+ /** A redacted preview of the matched text (first 4 chars + … + last 2). */
114
+ redacted: string;
115
+ }
116
+
117
+ // The scan appends the `g` flag to every pattern — a pattern that already
118
+ // declared it would produce `gg` and throw Invalid flag at scan time. Fail
119
+ // at import instead, where a developer sees it immediately.
120
+ for (const p of SECRET_PATTERNS) {
121
+ /* v8 ignore next 3 — developer-error guard, not reachable via valid patterns */
122
+ if (p.regex.flags.includes('g')) {
123
+ throw new Error(`secret pattern "${p.id}" must not declare the g flag (the scanner adds it)`);
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Run every secret pattern across `text` and return every hit.
129
+ *
130
+ * The scan is non-overlapping and bounded — each regex is `\b`-anchored or
131
+ * scheme-anchored, so a single credential can never span more than one match.
132
+ */
133
+ export function scanForSecrets(text: string): SecretHit[] {
134
+ const hits: SecretHit[] = [];
135
+ for (const pattern of SECRET_PATTERNS) {
136
+ // matchAll requires the `g` flag; build a per-call global copy so the
137
+ // module-level regexes never carry stateful lastIndex between scans,
138
+ // and so EVERY occurrence is reported (the contract says "every hit").
139
+ const global = new RegExp(pattern.regex.source, `${pattern.regex.flags}g`);
140
+ for (const match of text.matchAll(global)) {
141
+ const start = match.index;
142
+ const end = start + match[0].length;
143
+ hits.push({
144
+ patternId: pattern.id,
145
+ description: pattern.description,
146
+ start,
147
+ end,
148
+ redacted: redact(match[0]),
149
+ });
150
+ }
151
+ }
152
+ // Sort by start position so the redacted-log line reads in document order.
153
+ return hits.sort((a, b) => a.start - b.start);
154
+ }
155
+
156
+ /** Returns true if the text contains anything that looks like a secret. */
157
+ export function hasSecret(text: string): boolean {
158
+ return scanForSecrets(text).length > 0;
159
+ }
160
+
161
+ /**
162
+ * Replace the middle of a string with `…` for safe logging. Keeps the first
163
+ * 4 and last 2 characters when the input is long enough; otherwise just
164
+ * emits `<redacted:N>` so the length is still visible.
165
+ */
166
+ export function redact(value: string): string {
167
+ if (value.length <= 8) return `<redacted:${value.length}>`;
168
+ return `${value.slice(0, 4)}…${value.slice(-2)} (len=${value.length})`;
169
+ }