@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.
- package/LICENSE +21 -21
- package/dist/accessGrants.d.ts +156 -0
- package/dist/accessGrants.d.ts.map +1 -0
- package/dist/accessGrants.js +100 -0
- package/dist/accessGrants.js.map +1 -0
- package/dist/agentCommand.d.ts.map +1 -1
- package/dist/agentCommand.js +11 -1
- package/dist/agentCommand.js.map +1 -1
- package/dist/auth.d.ts +53 -0
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +26 -0
- package/dist/auth.js.map +1 -1
- package/dist/backupPolicy.d.ts +37 -0
- package/dist/backupPolicy.d.ts.map +1 -0
- package/dist/backupPolicy.js +50 -0
- package/dist/backupPolicy.js.map +1 -0
- package/dist/common.d.ts +21 -5
- package/dist/common.d.ts.map +1 -1
- package/dist/common.js +35 -6
- package/dist/common.js.map +1 -1
- package/dist/databaseImport.d.ts +145 -0
- package/dist/databaseImport.d.ts.map +1 -0
- package/dist/databaseImport.js +115 -0
- package/dist/databaseImport.js.map +1 -0
- package/dist/doctor.d.ts +16 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +25 -0
- package/dist/doctor.js.map +1 -1
- package/dist/githubApp.d.ts +236 -0
- package/dist/githubApp.d.ts.map +1 -0
- package/dist/githubApp.js +223 -0
- package/dist/githubApp.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/dist/kernel.d.ts +5 -2
- package/dist/kernel.d.ts.map +1 -1
- package/dist/kernel.js +11 -2
- package/dist/kernel.js.map +1 -1
- package/dist/management.d.ts +46 -2
- package/dist/management.d.ts.map +1 -1
- package/dist/management.js +68 -15
- package/dist/management.js.map +1 -1
- package/dist/multiNode.d.ts +325 -0
- package/dist/multiNode.d.ts.map +1 -0
- package/dist/multiNode.js +294 -0
- package/dist/multiNode.js.map +1 -0
- package/dist/ninedeployManifest.d.ts.map +1 -1
- package/dist/ninedeployManifest.js +6 -3
- package/dist/ninedeployManifest.js.map +1 -1
- package/dist/panelBackup.d.ts +73 -0
- package/dist/panelBackup.d.ts.map +1 -0
- package/dist/panelBackup.js +45 -0
- package/dist/panelBackup.js.map +1 -0
- package/dist/proxyConfig.d.ts +132 -0
- package/dist/proxyConfig.d.ts.map +1 -0
- package/dist/proxyConfig.js +105 -0
- package/dist/proxyConfig.js.map +1 -0
- package/dist/publicAccess.d.ts +64 -0
- package/dist/publicAccess.d.ts.map +1 -0
- package/dist/publicAccess.js +63 -0
- package/dist/publicAccess.js.map +1 -0
- package/dist/secretProviders.d.ts +119 -0
- package/dist/secretProviders.d.ts.map +1 -0
- package/dist/secretProviders.js +130 -0
- package/dist/secretProviders.js.map +1 -0
- package/dist/secretScan.js +1 -1
- package/dist/service.d.ts +27 -17
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +76 -26
- package/dist/service.js.map +1 -1
- package/dist/terminals.d.ts +284 -0
- package/dist/terminals.d.ts.map +1 -0
- package/dist/terminals.js +177 -0
- package/dist/terminals.js.map +1 -0
- package/dist/traffic.d.ts +276 -0
- package/dist/traffic.d.ts.map +1 -0
- package/dist/traffic.js +103 -0
- package/dist/traffic.js.map +1 -0
- package/dist/workspaces.d.ts +19 -1
- package/dist/workspaces.d.ts.map +1 -1
- package/dist/workspaces.js +26 -0
- package/dist/workspaces.js.map +1 -1
- package/package.json +5 -4
- package/src/accessGrants.ts +117 -0
- package/src/agentCommand.ts +67 -0
- package/src/ai.ts +32 -0
- package/src/auth.ts +323 -0
- package/src/backupPolicy.ts +55 -0
- package/src/common.ts +179 -0
- package/src/databaseImport.ts +138 -0
- package/src/doctor.ts +161 -0
- package/src/environments.ts +23 -0
- package/src/githubApp.ts +268 -0
- package/src/index.ts +30 -0
- package/src/insights.ts +78 -0
- package/src/kernel.ts +180 -0
- package/src/labels.ts +86 -0
- package/src/management.ts +620 -0
- package/src/multiNode.ts +354 -0
- package/src/ninedeployManifest.ts +363 -0
- package/src/panelBackup.ts +90 -0
- package/src/project.ts +42 -0
- package/src/proxyConfig.ts +139 -0
- package/src/publicAccess.ts +80 -0
- package/src/runtimeVersions.ts +232 -0
- package/src/secretProviders.ts +159 -0
- package/src/secretScan.ts +169 -0
- package/src/service.ts +1044 -0
- package/src/terminals.ts +212 -0
- package/src/traffic.ts +130 -0
- 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>;
|