@ninedeploy/schemas 0.10.5 → 0.16.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 (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 +325 -0
  46. package/dist/multiNode.d.ts.map +1 -0
  47. package/dist/multiNode.js +294 -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 +27 -17
  70. package/dist/service.d.ts.map +1 -1
  71. package/dist/service.js +76 -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 +354 -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 +1044 -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,117 @@
1
+ import { z } from 'zod';
2
+ import { id } from './common.js';
3
+
4
+ // ── Project and environment access grants (0.15) ────────────────────────────
5
+ // Request and response shapes for `/v1/workspaces/:wid/access-grants`,
6
+ // `/v1/projects/:id/access` and `/v1/access/me`. Grants are raise-only (owner
7
+ // decision O5): a user's effective role on a resource is the higher of their
8
+ // seat role and any matching, non-suspended grant; `owner` is never grantable.
9
+ // The role cap (no higher than the granter's own role) and the workspace
10
+ // consistency checks are enforced by the server. Design: DESIGN.md §4.
11
+
12
+ /** Mirrors `accessGrantRole` in `@ninedeploy/db`. */
13
+ export const accessGrantRole = z.enum(['viewer', 'member', 'admin']);
14
+ export type AccessGrantRole = z.infer<typeof accessGrantRole>;
15
+
16
+ /**
17
+ * The uniqueness key stored in `access_grants.target_key`: `p:<project>`,
18
+ * `e:<environment>` or `pe:<project>:<environment>`. SQLite unique indexes
19
+ * treat NULLs as distinct, so the pair alone could not be unique.
20
+ */
21
+ export function accessGrantTargetKey(target: { projectId?: number | null; environmentId?: number | null }): string {
22
+ const p = target.projectId ?? null;
23
+ const e = target.environmentId ?? null;
24
+ if (p !== null && e !== null) return `pe:${p}:${e}`;
25
+ if (p !== null) return `p:${p}`;
26
+ if (e !== null) return `e:${e}`;
27
+ throw new Error('an access grant needs a project or an environment');
28
+ }
29
+
30
+ const hasTarget = (v: { projectId?: number | null; environmentId?: number | null }) =>
31
+ v.projectId != null || v.environmentId != null;
32
+ const hasSubject = (v: { email?: string; userId?: number }) => (v.email === undefined) !== (v.userId === undefined);
33
+
34
+ /**
35
+ * POST /v1/workspaces/:wid/access-grants (workspace admin). Name the user by
36
+ * exactly one of `email` or `userId`, and target a project, an environment, or
37
+ * both (services linked to the project AND in the environment).
38
+ */
39
+ export const accessGrantCreate = z
40
+ .object({
41
+ email: z.string().trim().toLowerCase().email().max(254).optional(),
42
+ userId: id.optional(),
43
+ projectId: id.optional(),
44
+ environmentId: id.optional(),
45
+ role: accessGrantRole,
46
+ })
47
+ .strict()
48
+ .refine(hasSubject, { message: 'name the user by exactly one of email or userId', path: ['email'] })
49
+ .refine(hasTarget, { message: 'a grant needs a projectId, an environmentId, or both', path: ['projectId'] });
50
+ export type AccessGrantCreate = z.infer<typeof accessGrantCreate>;
51
+
52
+ /**
53
+ * PATCH /v1/workspaces/:wid/access-grants/:grantId: change the role, suspend
54
+ * or reinstate the grant, or both. `{role}` alone is the 0.15.0 contract and
55
+ * still means exactly that. `suspended: true` stops the grant counting (it
56
+ * stays listed, like a SCIM suspension); `false` makes it count again. The
57
+ * target and the user never change: revoke and grant again instead.
58
+ */
59
+ export const accessGrantUpdate = z
60
+ .object({ role: accessGrantRole.optional(), suspended: z.boolean().optional() })
61
+ .strict()
62
+ .refine((v) => v.role !== undefined || v.suspended !== undefined, {
63
+ message: 'change the role, suspended, or both',
64
+ path: ['role'],
65
+ });
66
+ export type AccessGrantUpdate = z.infer<typeof accessGrantUpdate>;
67
+
68
+ /** GET /v1/workspaces/:wid/access-grants query. Query strings arrive as text. */
69
+ export const accessGrantListQuery = z
70
+ .object({
71
+ userId: z.coerce.number().int().positive().optional(),
72
+ projectId: z.coerce.number().int().positive().optional(),
73
+ environmentId: z.coerce.number().int().positive().optional(),
74
+ })
75
+ .strict();
76
+ export type AccessGrantListQuery = z.infer<typeof accessGrantListQuery>;
77
+
78
+ const namedRef = z.object({ id, name: z.string() });
79
+ /** `users.name` is optional; the API sends `null` when it is unset. */
80
+ const userRef = z.object({ id, email: z.string(), name: z.string().nullable() });
81
+
82
+ /** One grant as the workspace admin sees it. */
83
+ export const accessGrant = z.object({
84
+ id,
85
+ workspaceId: id,
86
+ user: userRef,
87
+ project: namedRef.nullable(),
88
+ environment: namedRef.nullable(),
89
+ role: accessGrantRole,
90
+ suspended: z.boolean(),
91
+ createdAt: z.string(),
92
+ createdBy: z.object({ id, email: z.string() }).nullable(),
93
+ /** The user has no seat in the grant's workspace: they see only what grants cover. */
94
+ isGuest: z.boolean(),
95
+ });
96
+ export type AccessGrant = z.infer<typeof accessGrant>;
97
+
98
+ /** How a user reaches a project, as GET /v1/projects/:id/access explains it. */
99
+ export const projectAccessVia = z.enum(['operator', 'seat', 'grant', 'creator']);
100
+ export type ProjectAccessVia = z.infer<typeof projectAccessVia>;
101
+
102
+ /** GET /v1/projects/:id/access (project admin). */
103
+ export const projectAccessEntry = z.object({
104
+ user: userRef,
105
+ /** Effective role on the project (operators are `owner`). */
106
+ role: z.enum(['owner', 'admin', 'member', 'viewer']),
107
+ via: z.array(projectAccessVia).min(1),
108
+ });
109
+ export type ProjectAccessEntry = z.infer<typeof projectAccessEntry>;
110
+
111
+ /** GET /v1/access/me (self): the caller's own grants and guest workspaces. */
112
+ export const accessMe = z.object({
113
+ grants: z.array(accessGrant),
114
+ /** Workspaces the caller reaches only through grants (no seat). */
115
+ guestWorkspaces: z.array(z.object({ id, name: z.string(), slug: z.string() })),
116
+ });
117
+ export type AccessMe = z.infer<typeof accessMe>;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The one `docker run` line that starts a NineDeploy node agent (r175).
3
+ *
4
+ * It used to be written out three times — SSH bootstrap, the register API
5
+ * and the Servers page — and all three were wrong in different ways: the
6
+ * API/web copies named an image that does not exist
7
+ * (`ghcr.io/ninedeploy/server`), none of them told the image to run the
8
+ * agent instead of the panel, the auto-join copy had no enrolment token
9
+ * (so `/announce` always refused it), and the in-container agent could
10
+ * neither reach the docker socket (non-root image user) nor hand Traefik
11
+ * bind mounts that exist on the host.
12
+ *
13
+ * The agent keeps all state relative to its working directory, so it runs
14
+ * in AGENT_HOME, bind-mounted at the SAME path on the host: every `-v`
15
+ * the agent passes to the host daemon (Traefik config, acme.json, env files)
16
+ * then names a real host path, and a generated auto-join token survives a
17
+ * container re-create. It runs as root because docker-socket access is
18
+ * root-equivalent anyway and the socket is `root:docker 0660`.
19
+ */
20
+ export const AGENT_HOME = '/var/lib/ninedeploy-agent';
21
+ export const AGENT_CONTAINER_PORT = 4600;
22
+ export const AGENT_IMAGE_REPO = 'ghcr.io/ninedeploy/ninedeploy';
23
+
24
+ export interface AgentCommandOptions {
25
+ /** Host port the agent is published on. */
26
+ hostPort: number;
27
+ /** Image tag, e.g. `v0.10.0`; `latest` when the caller cannot pin it. */
28
+ imageTag: string;
29
+ /** Manual registration: sha256 of the shared token the core holds. */
30
+ tokenSha256?: string;
31
+ /** Auto-join: the core's public URL to announce to. */
32
+ masterUrl?: string;
33
+ /** Auto-join: the admin-issued enrolment secret (or a placeholder to fill in). */
34
+ enrolmentToken?: string;
35
+ }
36
+
37
+ /** Render a value for the copy-paste shell line (r473): bare when it is
38
+ * provably shell-safe (the common case — numbers, hex tokens, plain URLs),
39
+ * double-quoted otherwise. Unquoted, a panel URL carrying `&` would
40
+ * background the pasted command and mangle the rest of the line. */
41
+ function shellValue(value: string): string {
42
+ return /^[\w.:/=-]+$/.test(value) ? value : `"${value.replace(/(["$`\\])/g, '\\$1')}"`;
43
+ }
44
+
45
+ export function agentDockerRunCommand(opts: AgentCommandOptions): string {
46
+ const env: string[] = [
47
+ 'NINEDEPLOY_AGENT=1',
48
+ `NINEDEPLOY_AGENT_PORT=${AGENT_CONTAINER_PORT}`,
49
+ ];
50
+ if (opts.tokenSha256) env.push(`NINEDEPLOY_AGENT_TOKEN=${opts.tokenSha256}`);
51
+ if (opts.masterUrl) env.push(`NINEDEPLOY_MASTER_URL=${opts.masterUrl}`);
52
+ if (opts.enrolmentToken) env.push(`NINEDEPLOY_ENROLMENT_TOKEN=${opts.enrolmentToken}`);
53
+ return [
54
+ 'docker run -d --name ninedeploy-agent --restart unless-stopped',
55
+ '--user 0:0',
56
+ `-p ${opts.hostPort}:${AGENT_CONTAINER_PORT}`,
57
+ '-v /var/run/docker.sock:/var/run/docker.sock',
58
+ `-v ${AGENT_HOME}:${AGENT_HOME}`,
59
+ `-w ${AGENT_HOME}`,
60
+ ...env.map((e) => {
61
+ const eq = e.indexOf('=');
62
+ return `-e ${e.slice(0, eq)}=${shellValue(e.slice(eq + 1))}`;
63
+ }),
64
+ `${AGENT_IMAGE_REPO}:${opts.imageTag}`,
65
+ 'node /app/apps/server/dist/agent.js',
66
+ ].join(' ');
67
+ }
package/src/ai.ts ADDED
@@ -0,0 +1,32 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * AI failure diagnosis (BYO-key) configuration. The API key never lives
5
+ * here — it is stored separately, encrypted at rest — and this shape only
6
+ * carries the OpenAI-compatible endpoint and model to call.
7
+ */
8
+ export const aiConfig = z.object({
9
+ /** OpenAI-compatible chat-completions base URL (e.g. https://api.openai.com/v1). */
10
+ baseUrl: z.string().trim().url().max(300),
11
+ /** Model name as the upstream expects it (e.g. gpt-4o-mini, llama3.1). */
12
+ model: z.string().trim().min(1).max(120),
13
+ });
14
+
15
+ export type AiConfig = z.infer<typeof aiConfig>;
16
+
17
+ /** PUT /v1/ai/config body — the key is optional so base URL/model can change without re-entering it. */
18
+ export const aiConfigUpdate = aiConfig.extend({
19
+ apiKey: z.string().trim().min(8).max(400).optional(),
20
+ });
21
+
22
+ export type AiConfigUpdate = z.infer<typeof aiConfigUpdate>;
23
+
24
+ /** GET /v1/ai/config response — never carries key material. */
25
+ export const aiConfigStatus = z.object({
26
+ configured: z.boolean(),
27
+ baseUrl: z.string().nullable(),
28
+ model: z.string().nullable(),
29
+ hasApiKey: z.boolean(),
30
+ });
31
+
32
+ export type AiConfigStatus = z.infer<typeof aiConfigStatus>;
package/src/auth.ts ADDED
@@ -0,0 +1,323 @@
1
+ import { z } from 'zod';
2
+
3
+ export const register = z.object({
4
+ email: z.email().max(255),
5
+ password: z.string().min(8).max(128),
6
+ name: z.string().min(1).max(100).optional(),
7
+ /** First-run setup token (0.16): required by the bootstrap of an empty production instance. */
8
+ setupToken: z.string().max(200).optional(),
9
+ });
10
+ export type Register = z.infer<typeof register>;
11
+
12
+ /** Initial bootstrap — same shape as register, only works when no users exist. */
13
+ export const setup = register;
14
+ export type Setup = Register;
15
+
16
+ export const login = z.object({
17
+ email: z.email(),
18
+ password: z.string().min(1),
19
+ /** Required when the account has 2FA enabled. */
20
+ totpCode: z.string().regex(/^\d{6}$/).optional(),
21
+ });
22
+ export type Login = z.infer<typeof login>;
23
+
24
+ /** Verifying a two-factor action (enable / disable companion). */
25
+ export const twoFactorCode = z.object({ code: z.string().regex(/^\d{6}$/) });
26
+ export type TwoFactorCode = z.infer<typeof twoFactorCode>;
27
+
28
+ /** Regenerating the 2FA secret: password required when 2FA is already enabled. */
29
+ export const twoFactorSetup = z.object({ password: z.string().min(1) });
30
+ export type TwoFactorSetup = z.infer<typeof twoFactorSetup>;
31
+
32
+ /**
33
+ * Step-up proof for credential-planting actions (r502): the current password.
34
+ * Optional on the wire — an account without a usable password (SSO-only) may
35
+ * instead prove a FRESH sign-in (session younger than 10 minutes); the server
36
+ * decides which applies.
37
+ */
38
+ export const stepUp = z.object({ password: z.string().min(1).max(1024).optional() });
39
+ export type StepUp = z.infer<typeof stepUp>;
40
+
41
+ /** Turning 2FA on: the code from the authenticator + the step-up proof (r502). */
42
+ export const twoFactorEnable = twoFactorCode.extend({ password: z.string().min(1).max(1024).optional() });
43
+ export type TwoFactorEnable = z.infer<typeof twoFactorEnable>;
44
+
45
+ export const twoFactorDisable = z.object({
46
+ password: z.string().min(1),
47
+ code: z.string().regex(/^\d{6}$/),
48
+ });
49
+ export type TwoFactorDisable = z.infer<typeof twoFactorDisable>;
50
+
51
+ /** Self-service password change: requires the current password. */
52
+ export const passwordChange = z.object({
53
+ currentPassword: z.string().min(1),
54
+ newPassword: z.string().min(8).max(128),
55
+ });
56
+ export type PasswordChange = z.infer<typeof passwordChange>;
57
+
58
+ /** Admin-initiated password reset for another user. */
59
+ /**
60
+ * Admin-created user — same shape as register, no global role.
61
+ * A new user has no workspace memberships until an existing owner/admin
62
+ * invites them; the first user on a fresh install is auto-promoted to owner
63
+ * of a personal workspace by the auth module.
64
+ */
65
+ export const userCreate = z.object({
66
+ email: z.email().max(255),
67
+ password: z.string().min(8).max(128),
68
+ name: z.string().min(1).max(100).optional(),
69
+ });
70
+ export type UserCreate = z.infer<typeof userCreate>;
71
+
72
+ export const passwordReset = z.object({
73
+ newPassword: z.string().min(8).max(128),
74
+ });
75
+ export type PasswordReset = z.infer<typeof passwordReset>;
76
+
77
+ /**
78
+ * API-token scopes.
79
+ *
80
+ * Until 0.3.5 the `api_tokens.scopes` column existed but was never written
81
+ * (always `[]`) and never read, so every token carried its owner's FULL
82
+ * authority — including the instance-operator flag, which gates host-privileged
83
+ * deploys. A CI or MCP token was therefore an instance-root credential.
84
+ *
85
+ * The vocabulary is deliberately tiny and method-based, so it can be enforced
86
+ * in one place (`plugins/auth.ts`) instead of being annotated onto 51 route
87
+ * modules:
88
+ *
89
+ * read → safe methods only (GET / HEAD / OPTIONS)
90
+ * write → every method, but the request runs as a NON-operator even if
91
+ * the owner is one
92
+ * operator → no restriction beyond the owner's own authority
93
+ *
94
+ * An EMPTY scope list means "unrestricted" and is what every pre-0.3.5 token
95
+ * has. That is deliberate back-compat: silently downgrading tokens already
96
+ * deployed in someone's CI would break their pipeline on upgrade. New tokens
97
+ * should always be created with an explicit scope.
98
+ */
99
+ // ── API-token scopes (G-08 fine-grained) ──────────────────────────────────
100
+ // The pre-0.3.5 enum (`read` | `write` | `operator`) was a coarse
101
+ // binary/ternary. G-08 adds per-resource URI scopes
102
+ // (`nd://scope/{read,write,admin}/<resource>`) so an MCP / CI token
103
+ // can be limited to, say, `nd://scope/read/services +
104
+ // nd://scope/read/databases` and nothing else.
105
+ //
106
+ // The two forms are accepted in the same array; the old enum
107
+ // continues to be a valid shorthand (a `write` token expands to
108
+ // "any write" at the auth plugin, which is the pre-0.3.5
109
+ // behaviour for backwards compat).
110
+ const LEGACY_SCOPE = z.enum(['read', 'write', 'operator']);
111
+ const RESOURCE_SCOPE = z.string().regex(
112
+ /^nd:\/\/scope\/(read|write|admin)\/[a-z][a-z0-9_]{0,63}$/,
113
+ 'expected nd://scope/{read,write,admin}/<resource>',
114
+ );
115
+ export const apiTokenScope = z.union([LEGACY_SCOPE, RESOURCE_SCOPE]);
116
+ export type ApiTokenScope = z.infer<typeof apiTokenScope>;
117
+
118
+ /** All known resource names. The auth plugin checks these
119
+ * literally — adding a new resource is a one-line change
120
+ * in the auth plugin's resource map. Listed here so the
121
+ * CLI can suggest completions and the Zod regex can
122
+ * cross-check. */
123
+ export const KNOWN_SCOPE_RESOURCES = [
124
+ 'services',
125
+ 'databases',
126
+ 'domains',
127
+ 'deploys',
128
+ 'alerts',
129
+ 'webhooks',
130
+ 'env',
131
+ 'backups',
132
+ 'volumes',
133
+ 'projects',
134
+ 'users',
135
+ 'tokens',
136
+ 'notifications',
137
+ 'config',
138
+ 'topology',
139
+ 'health',
140
+ 'settings',
141
+ 'manifests',
142
+ 'domains_transfer',
143
+ 'backup_drill',
144
+ 'images',
145
+ 'audit',
146
+ 'firewall',
147
+ 'sso',
148
+ 'egress',
149
+ 'orchestrators',
150
+ 'housekeeping',
151
+ ] as const;
152
+ export type ScopeResource = (typeof KNOWN_SCOPE_RESOURCES)[number];
153
+
154
+ /**
155
+ * Grant or revoke the INSTANCE-operator flag (`PATCH /v1/users/:id/operator`).
156
+ *
157
+ * This is not a workspace role. It gates the operator-only routes and the
158
+ * host-privilege boundary (PM2/compose deploys, lifecycle hooks, docker-socket
159
+ * templates), so it can only be changed by an existing operator, and the last
160
+ * one cannot revoke itself.
161
+ */
162
+ export const operatorGrant = z.object({
163
+ isOperator: z.boolean(),
164
+ });
165
+ export type OperatorGrant = z.infer<typeof operatorGrant>;
166
+
167
+ /** Forgot-password request. Always answers 200 (no user enumeration). */
168
+ export const forgotPassword = z.object({
169
+ email: z.email(),
170
+ });
171
+ export type ForgotPassword = z.infer<typeof forgotPassword>;
172
+
173
+ /** Complete a reset with the token from the email / admin-issued link. */
174
+ export const passwordResetWithToken = z.object({
175
+ token: z.string().min(20).max(128),
176
+ newPassword: z.string().min(8).max(128),
177
+ });
178
+ export type PasswordResetWithToken = z.infer<typeof passwordResetWithToken>;
179
+
180
+ /** Tokens issued after a successful login/refresh. */
181
+ export const tokenPair = z.object({
182
+ accessToken: z.string(),
183
+ refreshToken: z.string(),
184
+ expiresIn: z.number().int().positive(),
185
+ });
186
+ export type TokenPair = z.infer<typeof tokenPair>;
187
+
188
+ export const refresh = z.object({
189
+ refreshToken: z.string().min(1),
190
+ });
191
+ export type Refresh = z.infer<typeof refresh>;
192
+
193
+ /**
194
+ * Public user representation (never includes passwordHash).
195
+ * The legacy global `role` field was removed; authorization is per-workspace
196
+ * now. The effective "is operator" check is computed by joining
197
+ * `workspace_members` and asking whether the user holds owner/admin in any
198
+ * workspace.
199
+ */
200
+ export const publicUser = z.object({
201
+ id: z.number().int(),
202
+ email: z.email(),
203
+ name: z.string().nullable(),
204
+ /** True iff the user holds owner/admin in at least one workspace. */
205
+ isOperator: z.boolean(),
206
+ /** Number of workspaces the user belongs to. Computed by the server. */
207
+ workspaceCount: z.number().int().nonnegative(),
208
+ /**
209
+ * r540: workspaces this user owns — the ones deleting the account hands to
210
+ * the operator (or `transferTo`). Only `GET /v1/users` fills it.
211
+ */
212
+ ownedWorkspaces: z.array(z.object({ id: z.number().int(), name: z.string() })).optional(),
213
+ createdAt: z.string().datetime(),
214
+ });
215
+ export type PublicUser = z.infer<typeof publicUser>;
216
+
217
+ export const session = z.object({
218
+ user: publicUser,
219
+ tokens: tokenPair,
220
+ });
221
+ export type Session = z.infer<typeof session>;
222
+
223
+ /**
224
+ * r603: `POST /auth/password` — a session plus how many passkeys still sign in
225
+ * to the account (a change keeps them; a reset deletes them). Optional so a
226
+ * client talking to an older server keeps working.
227
+ */
228
+ export const passwordChangeResult = session.extend({
229
+ passkeysRemaining: z.number().int().nonnegative().optional(),
230
+ });
231
+ export type PasswordChangeResult = z.infer<typeof passwordChangeResult>;
232
+
233
+ // ── API tokens (for the CLI / CI) ─────────────────────────────────────────
234
+ export const createApiToken = z.object({
235
+ // Lenient on purpose: this endpoint historically accepted any string, sliced
236
+ // it to 100 chars and fell back to "cli" when empty. Rejecting outright would
237
+ // break CLI/CI callers on upgrade.
238
+ name: z
239
+ .string()
240
+ .optional()
241
+ .transform((v) => (v ?? '').slice(0, 100) || 'cli'),
242
+ /**
243
+ * Defaults to `['read']` — a NEW token without explicit scopes is a
244
+ * read-only token, not an unrestricted one. The unrestricted meaning of an
245
+ * EMPTY list survives only for legacy rows created before 0.3.5 (the
246
+ * resolver still maps `[]` → unrestricted); new tokens are never handed
247
+ * their owner's full authority by default — and an explicit `[]` is
248
+ * refused, since it would be stored as exactly that legacy unrestricted row.
249
+ */
250
+ scopes: z.array(apiTokenScope).min(1).max(3).default(['read']),
251
+ /**
252
+ * Defaults to a one-year lifetime. A standing credential that never expires
253
+ * was the pre-0.3.5 behaviour and is what made a leaked CI token
254
+ * effectively permanent; an operator who wants that must now say so
255
+ * explicitly (3650).
256
+ */
257
+ expiresInDays: z.number().int().min(1).max(3650).default(365),
258
+ });
259
+ /** Parsed shape (post-defaults) — what the route handler works with. */
260
+ export type CreateApiToken = z.infer<typeof createApiToken>;
261
+ /**
262
+ * Caller-facing shape: `name` and `scopes` are optional on the wire and filled
263
+ * in by the schema's defaults. The SDK takes this so `tokens.create()` and
264
+ * `tokens.create({ name: 'ci' })` both stay valid.
265
+ */
266
+ export type CreateApiTokenInput = z.input<typeof createApiToken>;
267
+
268
+ export const apiToken = z.object({
269
+ id: z.number().int(),
270
+ name: z.string(),
271
+ /** Empty array = a legacy, unrestricted token. The UI should flag those. */
272
+ scopes: z.array(z.string()),
273
+ lastUsedAt: z.string().datetime().nullable(),
274
+ expiresAt: z.string().datetime().nullable(),
275
+ createdAt: z.string().datetime(),
276
+ });
277
+ export type ApiToken = z.infer<typeof apiToken>;
278
+
279
+ /** Returned once at creation time — the raw token is never retrievable again. */
280
+ export const createdApiToken = z.object({
281
+ id: z.number().int(),
282
+ name: z.string(),
283
+ token: z.string(),
284
+ scopes: z.array(z.string()),
285
+ expiresAt: z.string().datetime().nullable(),
286
+ createdAt: z.string().datetime(),
287
+ });
288
+ export type CreatedApiToken = z.infer<typeof createdApiToken>;
289
+
290
+ // ── passkeys (WebAuthn) ────────────────────────────────────────────────────
291
+ /** Registration verification: label for the new credential + the browser's response payload. */
292
+ export const passkeyRegisterVerify = z.object({
293
+ name: z.string().min(1).max(100),
294
+ response: z.record(z.string(), z.unknown()),
295
+ /** r502: step-up proof — see `stepUp`. */
296
+ password: z.string().min(1).max(1024).optional(),
297
+ });
298
+ export type PasskeyRegisterVerify = z.infer<typeof passkeyRegisterVerify>;
299
+
300
+ /** Login verification: just the browser's authentication response (discoverable credentials). */
301
+ export const passkeyLoginVerify = z.object({
302
+ response: z.record(z.string(), z.unknown()),
303
+ });
304
+ export type PasskeyLoginVerify = z.infer<typeof passkeyLoginVerify>;
305
+
306
+ export const passkeyCredential = z.object({
307
+ id: z.number().int(),
308
+ name: z.string(),
309
+ createdAt: z.string().datetime(),
310
+ });
311
+ export type PasskeyCredential = z.infer<typeof passkeyCredential>;
312
+
313
+ // ── sessions (refresh-token backing store) ─────────────────────────────────
314
+ export const activeSession = z.object({
315
+ id: z.number().int(),
316
+ ip: z.string().nullable(),
317
+ userAgent: z.string().nullable(),
318
+ createdAt: z.string().datetime(),
319
+ lastUsedAt: z.string().datetime().nullable(),
320
+ current: z.boolean(),
321
+ });
322
+ export type ActiveSession = z.infer<typeof activeSession>;
323
+
@@ -0,0 +1,55 @@
1
+ import { z } from 'zod';
2
+
3
+ // ── Per-database backup policy (0.12) ──────────────────────────────────────
4
+ /** Bounds for both retention counts. At least one dump is always kept, so a
5
+ * policy can never prune a database's last good backup. */
6
+ export const BACKUP_RETAIN_MIN = 1;
7
+ export const BACKUP_RETAIN_MAX = 365;
8
+ /** What a database WITHOUT a policy gets: the built-in schedule. */
9
+ export const DEFAULT_BACKUP_RETAIN_COUNT = 7;
10
+
11
+ const retain = z.number().int().min(BACKUP_RETAIN_MIN).max(BACKUP_RETAIN_MAX);
12
+
13
+ /**
14
+ * PUT /v1/databases/:id/backup-policy — the whole policy (PUT replaces).
15
+ * The cron expression is validated with croner (5-part) at the route, the
16
+ * same rule scheduled jobs use; the schema package has no cron dependency.
17
+ */
18
+ export const backupPolicyInput = z
19
+ .object({
20
+ enabled: z.boolean().default(true),
21
+ cron: z.string().trim().min(1).max(120),
22
+ retainCount: retain,
23
+ /** Remote copies kept; null/omitted = the remote copies of the newest `retainCount` dumps. */
24
+ retainRemoteCount: retain.nullable().optional().transform((v) => v ?? null),
25
+ /** A `backup_destinations` id; null/omitted = the active destination. */
26
+ destinationId: z.number().int().positive().nullable().optional().transform((v) => v ?? null),
27
+ /** true = keep this database's scheduled dumps on the panel host only. */
28
+ localOnly: z.boolean().optional().transform((v) => v ?? false),
29
+ })
30
+ .refine((p) => !(p.localOnly && p.destinationId != null), {
31
+ message: 'localOnly and destinationId are mutually exclusive',
32
+ path: ['destinationId'],
33
+ })
34
+ .refine((p) => !(p.localOnly && p.retainRemoteCount != null), {
35
+ message: 'retainRemoteCount has no effect on a local-only policy',
36
+ path: ['retainRemoteCount'],
37
+ });
38
+ export type BackupPolicyInput = z.input<typeof backupPolicyInput>;
39
+
40
+ /** GET/PUT response. `configured: false` = no policy row: the built-in
41
+ * schedule (daily, 7 kept, active destination) applies and `cron` is null. */
42
+ export const backupPolicy = z.object({
43
+ databaseId: z.number().int(),
44
+ configured: z.boolean(),
45
+ enabled: z.boolean(),
46
+ cron: z.string().nullable(),
47
+ retainCount: z.number().int(),
48
+ retainRemoteCount: z.number().int().nullable(),
49
+ destinationId: z.number().int().nullable(),
50
+ localOnly: z.boolean(),
51
+ /** Next scheduled run for a configured, enabled policy (ISO); null otherwise. */
52
+ nextRunAt: z.string().nullable(),
53
+ updatedAt: z.string().nullable(),
54
+ });
55
+ export type BackupPolicy = z.infer<typeof backupPolicy>;