@wpelevator/tug 0.1.0 → 0.2.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.
- package/README.md +11 -6
- package/dist/agent.d.ts +9 -5
- package/dist/agent.js +215 -52
- package/dist/archive.d.ts +2 -0
- package/dist/archive.js +20 -0
- package/dist/artifacts.d.ts +23 -0
- package/dist/artifacts.js +238 -0
- package/dist/build.js +17 -2
- package/dist/cli-build.d.ts +15 -0
- package/dist/cli-build.js +50 -0
- package/dist/cli-config.d.ts +20 -0
- package/dist/cli-config.js +47 -0
- package/dist/cli-install.d.ts +7 -0
- package/dist/cli-install.js +31 -0
- package/dist/cli.js +191 -93
- package/dist/compose.d.ts +12 -2
- package/dist/compose.js +85 -7
- package/dist/control.d.ts +15 -1
- package/dist/control.js +583 -144
- package/dist/db.d.ts +8 -0
- package/dist/db.js +12 -3
- package/dist/ingress.js +5 -3
- package/dist/protocol.d.ts +44 -0
- package/dist/rollout.d.ts +72 -0
- package/dist/rollout.js +319 -0
- package/dist/templates/control-install.sh +1 -1
- package/dist/templates/install-docker.sh +85 -0
- package/dist/templates/node-install.sh +1 -1
- package/dist/tokens.d.ts +91 -0
- package/dist/tokens.js +165 -0
- package/dist/util.d.ts +3 -0
- package/dist/util.js +11 -0
- package/package.json +5 -2
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
set -eu
|
|
2
|
+
if docker version >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
|
|
3
|
+
echo 'Docker Engine and Compose are already available.' >&2
|
|
4
|
+
exit 0
|
|
5
|
+
fi
|
|
6
|
+
os_release="${TUG_OS_RELEASE:-/etc/os-release}"
|
|
7
|
+
[ -r "$os_release" ] || { echo 'Docker bootstrap supports Debian, Ubuntu, Fedora and RHEL-compatible distributions with systemd.' >&2; exit 20; }
|
|
8
|
+
. "$os_release"
|
|
9
|
+
unsupported() { echo "Unsupported Docker bootstrap $1; install Docker Engine and Compose manually." >&2; exit 20; }
|
|
10
|
+
major="${VERSION_ID:-}"; major="${major%%.*}"
|
|
11
|
+
case "$ID" in
|
|
12
|
+
ubuntu|debian)
|
|
13
|
+
family=apt
|
|
14
|
+
case "$ID:${VERSION_CODENAME:-}" in
|
|
15
|
+
ubuntu:jammy|ubuntu:noble|ubuntu:resolute|ubuntu:stonking|debian:bookworm|debian:trixie) ;;
|
|
16
|
+
*) unsupported "release: $ID ${VERSION_CODENAME:-unknown}" ;;
|
|
17
|
+
esac
|
|
18
|
+
repo_os=$ID ;;
|
|
19
|
+
fedora)
|
|
20
|
+
family=dnf; repo_os=fedora
|
|
21
|
+
case "$major" in 42|43|44) ;; *) unsupported "release: $ID ${VERSION_ID:-unknown}" ;; esac ;;
|
|
22
|
+
rhel|centos|rocky|almalinux)
|
|
23
|
+
family=dnf
|
|
24
|
+
# Docker publishes one EL repository per RHEL major; Rocky, AlmaLinux and CentOS Stream use the centos one.
|
|
25
|
+
if [ "$ID" = rhel ]; then repo_os=rhel; else repo_os=centos; fi
|
|
26
|
+
case "$major" in 9|10) ;; *) unsupported "release: $ID ${VERSION_ID:-unknown}" ;; esac ;;
|
|
27
|
+
*) unsupported "OS: $ID" ;;
|
|
28
|
+
esac
|
|
29
|
+
# Print the selected package family and repository without touching the host (used by tests).
|
|
30
|
+
if [ "${TUG_BOOTSTRAP_DETECT:-}" = 1 ]; then echo "$family $repo_os"; exit 0; fi
|
|
31
|
+
[ "$(id -u)" = 0 ] || { echo 'Docker installation requires root; use a root SSH target.' >&2; exit 20; }
|
|
32
|
+
[ -d /run/systemd/system ] || { echo 'Docker bootstrap requires systemd.' >&2; exit 20; }
|
|
33
|
+
# Preserve existing Docker installations. Install only a missing Compose plugin.
|
|
34
|
+
if command -v docker >/dev/null 2>&1; then
|
|
35
|
+
docker version >/dev/null 2>&1 || { echo 'Docker exists but is not accessible; repair/start it before installing Tug.' >&2; exit 20; }
|
|
36
|
+
engine_present=true
|
|
37
|
+
else
|
|
38
|
+
engine_present=false
|
|
39
|
+
fi
|
|
40
|
+
conflict() { echo "Conflicting package $1 is installed; review Docker's installation instructions before replacing it." >&2; exit 20; }
|
|
41
|
+
if [ "$family" = apt ]; then
|
|
42
|
+
[ -n "${VERSION_CODENAME:-}" ] || { echo 'OS release must declare VERSION_CODENAME.' >&2; exit 20; }
|
|
43
|
+
export DEBIAN_FRONTEND=noninteractive
|
|
44
|
+
if [ "$engine_present" = false ]; then
|
|
45
|
+
for package in docker.io docker-compose docker-compose-v2 podman-docker containerd runc; do
|
|
46
|
+
if dpkg-query -W -f='${Status}' "$package" 2>/dev/null | grep -q 'install ok installed'; then conflict "$package"; fi
|
|
47
|
+
done
|
|
48
|
+
fi
|
|
49
|
+
apt-get update >&2
|
|
50
|
+
apt-get install -y ca-certificates curl >&2
|
|
51
|
+
install -m 0755 -d /etc/apt/keyrings
|
|
52
|
+
curl -fsSL "https://download.docker.com/linux/$repo_os/gpg" -o /etc/apt/keyrings/docker.asc
|
|
53
|
+
chmod a+r /etc/apt/keyrings/docker.asc
|
|
54
|
+
cat > /etc/apt/sources.list.d/docker.sources <<REPO
|
|
55
|
+
Types: deb
|
|
56
|
+
URIs: https://download.docker.com/linux/$repo_os
|
|
57
|
+
Suites: $VERSION_CODENAME
|
|
58
|
+
Components: stable
|
|
59
|
+
Architectures: $(dpkg --print-architecture)
|
|
60
|
+
Signed-By: /etc/apt/keyrings/docker.asc
|
|
61
|
+
REPO
|
|
62
|
+
apt-get update >&2
|
|
63
|
+
if [ "$engine_present" = true ]; then
|
|
64
|
+
apt-get install -y --no-upgrade --no-remove docker-compose-plugin >&2
|
|
65
|
+
else
|
|
66
|
+
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin >&2
|
|
67
|
+
fi
|
|
68
|
+
else
|
|
69
|
+
if [ "$engine_present" = false ]; then
|
|
70
|
+
for package in docker docker-client docker-client-latest docker-common docker-latest docker-logrotate docker-engine moby-engine podman-docker docker-compose; do
|
|
71
|
+
if rpm -q "$package" >/dev/null 2>&1; then conflict "$package"; fi
|
|
72
|
+
done
|
|
73
|
+
fi
|
|
74
|
+
# curl-minimal already provides curl on many EL images; installing curl over it conflicts.
|
|
75
|
+
command -v curl >/dev/null 2>&1 || dnf install -y curl >&2
|
|
76
|
+
curl -fsSL "https://download.docker.com/linux/$repo_os/docker-ce.repo" -o /etc/yum.repos.d/docker-ce.repo
|
|
77
|
+
if [ "$engine_present" = true ]; then
|
|
78
|
+
dnf install -y docker-compose-plugin >&2
|
|
79
|
+
else
|
|
80
|
+
dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin >&2
|
|
81
|
+
fi
|
|
82
|
+
fi
|
|
83
|
+
[ "$engine_present" = true ] || systemctl enable --now docker >&2
|
|
84
|
+
docker version >/dev/null
|
|
85
|
+
docker compose version >/dev/null
|
package/dist/tokens.d.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { Request, RequestHandler } from 'express';
|
|
2
|
+
import type { Db } from './db.ts';
|
|
3
|
+
/** Roles are cumulative: `deploy` includes `read`, and `admin` includes both. */
|
|
4
|
+
export declare const ROLES: readonly ["read", "deploy", "admin"];
|
|
5
|
+
export type Role = typeof ROLES[number];
|
|
6
|
+
export type RouteRole = Role | 'public';
|
|
7
|
+
/** Name recorded in the audit trail for the root operator token. */
|
|
8
|
+
export declare const OPERATOR_ACTOR = "operator";
|
|
9
|
+
/** Reserved actor for rollbacks control starts on its own. Token names cannot contain a colon, so no token can use it. */
|
|
10
|
+
export declare const AUTO_ROLLBACK_ACTOR = "system:auto-rollback";
|
|
11
|
+
export declare const TOKEN_PREFIX = "tug_tok";
|
|
12
|
+
export declare const TOKEN_NAME_PATTERN: RegExp;
|
|
13
|
+
export declare const PROJECT_NAME_PATTERN: RegExp;
|
|
14
|
+
/**
|
|
15
|
+
* The single source of truth for who may call what. Every `/api/v1` route must appear here: createApp refuses to
|
|
16
|
+
* register an unlisted route, and a test compares this table with the live router. `public` routes do their own
|
|
17
|
+
* credential check (the registry hook) and are not behind the token guard. docs/access-control.md mirrors this table.
|
|
18
|
+
*/
|
|
19
|
+
export declare const ROUTE_ROLES: Record<string, RouteRole>;
|
|
20
|
+
/** The authenticated caller of an API request. `projects` is null when the credential is not project-scoped. */
|
|
21
|
+
export interface Actor {
|
|
22
|
+
name: string;
|
|
23
|
+
role: Role;
|
|
24
|
+
projects: string[] | null;
|
|
25
|
+
}
|
|
26
|
+
export interface TokenRow {
|
|
27
|
+
id: string;
|
|
28
|
+
name: string;
|
|
29
|
+
token_hash: string;
|
|
30
|
+
role: Role;
|
|
31
|
+
projects_json: string | null;
|
|
32
|
+
created_at: string;
|
|
33
|
+
created_by: string;
|
|
34
|
+
last_used_at: string | null;
|
|
35
|
+
expires_at: string | null;
|
|
36
|
+
revoked_at: string | null;
|
|
37
|
+
}
|
|
38
|
+
export interface TokenInfo {
|
|
39
|
+
name: string;
|
|
40
|
+
role: Role;
|
|
41
|
+
projects: string[] | null;
|
|
42
|
+
createdAt: string;
|
|
43
|
+
createdBy: string;
|
|
44
|
+
lastUsedAt: string | null;
|
|
45
|
+
expiresAt: string | null;
|
|
46
|
+
revokedAt: string | null;
|
|
47
|
+
}
|
|
48
|
+
declare module 'express-serve-static-core' {
|
|
49
|
+
interface Request {
|
|
50
|
+
actor?: Actor;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export declare const isRole: (value: unknown) => value is Role;
|
|
54
|
+
export declare const hasRole: (actor: Actor, required: Role) => boolean;
|
|
55
|
+
/** Validates a token request body, throwing a 400 error describing the first problem. */
|
|
56
|
+
export declare function parseTokenRequest(body: unknown): {
|
|
57
|
+
name: string;
|
|
58
|
+
role: Role;
|
|
59
|
+
projects: string[] | null;
|
|
60
|
+
expiresAt: string | null;
|
|
61
|
+
};
|
|
62
|
+
/** Creates a token. The plaintext is returned once; only its hash is stored. */
|
|
63
|
+
export declare function createToken(db: Db, request: ReturnType<typeof parseTokenRequest>, createdBy: string): Promise<TokenInfo & {
|
|
64
|
+
token: string;
|
|
65
|
+
}>;
|
|
66
|
+
export declare function listTokens(db: Db): Promise<TokenInfo[]>;
|
|
67
|
+
/** Revokes a token immediately. Returns false if no such token exists; revoking twice keeps the first revocation time. */
|
|
68
|
+
export declare function revokeToken(db: Db, name: string): Promise<boolean>;
|
|
69
|
+
/** Resolves a bearer value to an actor by hash lookup: the root operator token first, then named tokens. */
|
|
70
|
+
export declare function authenticate(db: Db, raw: string): Promise<Actor | null>;
|
|
71
|
+
/**
|
|
72
|
+
* Which project a request targets: a name, `null` for control-wide data (admin only), or `undefined` when it cannot be
|
|
73
|
+
* resolved (for example an unknown ID). Unresolvable targets are denied to scoped tokens without saying why, so they
|
|
74
|
+
* cannot probe which IDs or projects exist.
|
|
75
|
+
*/
|
|
76
|
+
export type ProjectResolver = (req: Request) => string | null | undefined | Promise<string | null | undefined>;
|
|
77
|
+
export declare const bodyProject: ProjectResolver;
|
|
78
|
+
export declare const queryProject: ProjectResolver;
|
|
79
|
+
/** Throws 403 unless the actor may act on `project`. Used where the project is only known after the body is parsed. */
|
|
80
|
+
export declare function assertProject(actor: Actor, project: string): void;
|
|
81
|
+
/** Builds the request guard: authenticate (401), check the role (403), then check project scope (403). */
|
|
82
|
+
export declare function createGuard(db: Db): (required: Role, resolve?: ProjectResolver) => RequestHandler;
|
|
83
|
+
/** Appends a control-originated audit event naming the acting token. */
|
|
84
|
+
export declare function recordEvent(db: Db, event: {
|
|
85
|
+
actor: string;
|
|
86
|
+
type: string;
|
|
87
|
+
message: string;
|
|
88
|
+
project?: string | null;
|
|
89
|
+
deploymentId?: string | null;
|
|
90
|
+
metadata?: Record<string, unknown>;
|
|
91
|
+
}): Promise<void>;
|
package/dist/tokens.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { hashToken, id, now, token } from "./util.js";
|
|
2
|
+
/** Roles are cumulative: `deploy` includes `read`, and `admin` includes both. */
|
|
3
|
+
export const ROLES = ['read', 'deploy', 'admin'];
|
|
4
|
+
const RANK = { read: 1, deploy: 2, admin: 3 };
|
|
5
|
+
/** Name recorded in the audit trail for the root operator token. */
|
|
6
|
+
export const OPERATOR_ACTOR = 'operator';
|
|
7
|
+
/** Reserved actor for rollbacks control starts on its own. Token names cannot contain a colon, so no token can use it. */
|
|
8
|
+
export const AUTO_ROLLBACK_ACTOR = 'system:auto-rollback';
|
|
9
|
+
export const TOKEN_PREFIX = 'tug_tok';
|
|
10
|
+
export const TOKEN_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/;
|
|
11
|
+
export const PROJECT_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
|
|
12
|
+
/** `last_used_at` is refreshed at most this often per token, so reads do not turn into writes. */
|
|
13
|
+
const LAST_USED_INTERVAL_MS = 60_000;
|
|
14
|
+
/**
|
|
15
|
+
* The single source of truth for who may call what. Every `/api/v1` route must appear here: createApp refuses to
|
|
16
|
+
* register an unlisted route, and a test compares this table with the live router. `public` routes do their own
|
|
17
|
+
* credential check (the registry hook) and are not behind the token guard. docs/access-control.md mirrors this table.
|
|
18
|
+
*/
|
|
19
|
+
export const ROUTE_ROLES = {
|
|
20
|
+
'GET /api/v1/status': 'read',
|
|
21
|
+
'GET /api/v1/events': 'read',
|
|
22
|
+
'GET /api/v1/projects/:project': 'read',
|
|
23
|
+
'GET /api/v1/deployments/:id': 'read',
|
|
24
|
+
'GET /api/v1/volumes': 'read',
|
|
25
|
+
'GET /api/v1/secrets': 'read',
|
|
26
|
+
'POST /api/v1/projects': 'deploy',
|
|
27
|
+
'POST /api/v1/deployments': 'deploy',
|
|
28
|
+
'POST /api/v1/deployments/:id/cancel': 'deploy',
|
|
29
|
+
'POST /api/v1/rollback': 'deploy',
|
|
30
|
+
'POST /api/v1/restart': 'deploy',
|
|
31
|
+
'POST /api/v1/logs': 'deploy',
|
|
32
|
+
'GET /api/v1/commands/:id': 'deploy',
|
|
33
|
+
'POST /api/v1/secrets': 'admin',
|
|
34
|
+
'DELETE /api/v1/secrets/:name': 'admin',
|
|
35
|
+
'DELETE /api/v1/volumes/:name': 'admin',
|
|
36
|
+
'GET /api/v1/nodes': 'admin',
|
|
37
|
+
'POST /api/v1/nodes/register': 'admin',
|
|
38
|
+
'POST /api/v1/nodes/enroll-token': 'admin',
|
|
39
|
+
'POST /api/v1/nodes/:name/rotate-registry-token': 'admin',
|
|
40
|
+
'POST /api/v1/nodes/:name/revoke-registry-token': 'admin',
|
|
41
|
+
'POST /api/v1/nodes/:name/rotate-wg-key': 'admin',
|
|
42
|
+
'POST /api/v1/nodes/:name/ingress': 'admin',
|
|
43
|
+
'POST /api/v1/control/backups/run': 'admin',
|
|
44
|
+
'POST /api/v1/control/restore': 'admin',
|
|
45
|
+
'GET /api/v1/diagnostics': 'admin',
|
|
46
|
+
'POST /api/v1/diagnostics/nodes': 'admin',
|
|
47
|
+
'POST /api/v1/tokens': 'admin',
|
|
48
|
+
'GET /api/v1/tokens': 'admin',
|
|
49
|
+
'DELETE /api/v1/tokens/:name': 'admin',
|
|
50
|
+
'POST /api/v1/registry/auth': 'public'
|
|
51
|
+
};
|
|
52
|
+
const statusError = (status, message) => Object.assign(new Error(message), { status });
|
|
53
|
+
export const isRole = (value) => typeof value === 'string' && ROLES.includes(value);
|
|
54
|
+
export const hasRole = (actor, required) => RANK[actor.role] >= RANK[required];
|
|
55
|
+
/** Validates a token request body, throwing a 400 error describing the first problem. */
|
|
56
|
+
export function parseTokenRequest(body) {
|
|
57
|
+
const input = (body && typeof body === 'object' ? body : {});
|
|
58
|
+
const { name, role, projects, expiresInSeconds } = input;
|
|
59
|
+
if (typeof name !== 'string' || !TOKEN_NAME_PATTERN.test(name))
|
|
60
|
+
throw statusError(400, 'token name must be 1-64 letters, numbers, dots, underscores or hyphens, starting with a letter or number');
|
|
61
|
+
if (name === OPERATOR_ACTOR)
|
|
62
|
+
throw statusError(400, `token name ${OPERATOR_ACTOR} is reserved for the root operator token`);
|
|
63
|
+
if (!isRole(role))
|
|
64
|
+
throw statusError(400, `role must be one of ${ROLES.join(', ')}`);
|
|
65
|
+
let scope = null;
|
|
66
|
+
if (projects !== undefined && projects !== null) {
|
|
67
|
+
if (!Array.isArray(projects) || !projects.length || projects.length > 100 || projects.some(p => typeof p !== 'string' || !PROJECT_NAME_PATTERN.test(p)))
|
|
68
|
+
throw statusError(400, 'projects must be a nonempty list of valid project names, or omitted for all projects');
|
|
69
|
+
if (role === 'admin')
|
|
70
|
+
throw statusError(400, 'admin tokens manage the whole control plane and cannot be limited to projects');
|
|
71
|
+
scope = [...new Set(projects)].sort();
|
|
72
|
+
}
|
|
73
|
+
let expiresAt = null;
|
|
74
|
+
if (expiresInSeconds !== undefined && expiresInSeconds !== null) {
|
|
75
|
+
if (typeof expiresInSeconds !== 'number' || !Number.isSafeInteger(expiresInSeconds) || expiresInSeconds < 1 || expiresInSeconds > 10 * 365 * 86400)
|
|
76
|
+
throw statusError(400, 'expiresInSeconds must be a positive integer of at most ten years');
|
|
77
|
+
expiresAt = new Date(Date.now() + expiresInSeconds * 1000).toISOString();
|
|
78
|
+
}
|
|
79
|
+
return { name, role, projects: scope, expiresAt };
|
|
80
|
+
}
|
|
81
|
+
const info = (row) => ({ name: row.name, role: row.role, projects: row.projects_json ? JSON.parse(row.projects_json) : null, createdAt: row.created_at, createdBy: row.created_by, lastUsedAt: row.last_used_at, expiresAt: row.expires_at, revokedAt: row.revoked_at });
|
|
82
|
+
/** Creates a token. The plaintext is returned once; only its hash is stored. */
|
|
83
|
+
export async function createToken(db, request, createdBy) {
|
|
84
|
+
const value = token(TOKEN_PREFIX);
|
|
85
|
+
try {
|
|
86
|
+
await db.run('INSERT INTO api_tokens(id,name,token_hash,role,projects_json,created_at,created_by,expires_at) VALUES(?,?,?,?,?,?,?,?)', id('tok'), request.name, hashToken(value), request.role, request.projects ? JSON.stringify(request.projects) : null, now(), createdBy, request.expiresAt);
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
if (/UNIQUE constraint/.test(e.message))
|
|
90
|
+
throw statusError(409, `token name ${request.name} is already used (revoked tokens keep their names for the audit trail)`);
|
|
91
|
+
throw e;
|
|
92
|
+
}
|
|
93
|
+
return { ...info((await db.get('SELECT * FROM api_tokens WHERE name=?', request.name))), token: value };
|
|
94
|
+
}
|
|
95
|
+
export async function listTokens(db) {
|
|
96
|
+
return (await db.all('SELECT * FROM api_tokens ORDER BY created_at, name')).map(info);
|
|
97
|
+
}
|
|
98
|
+
/** Revokes a token immediately. Returns false if no such token exists; revoking twice keeps the first revocation time. */
|
|
99
|
+
export async function revokeToken(db, name) {
|
|
100
|
+
const row = await db.get('SELECT * FROM api_tokens WHERE name=?', name);
|
|
101
|
+
if (!row)
|
|
102
|
+
return false;
|
|
103
|
+
await db.run('UPDATE api_tokens SET revoked_at=? WHERE id=? AND revoked_at IS NULL', now(), row.id);
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
/** Resolves a bearer value to an actor by hash lookup: the root operator token first, then named tokens. */
|
|
107
|
+
export async function authenticate(db, raw) {
|
|
108
|
+
if (!raw)
|
|
109
|
+
return null;
|
|
110
|
+
const hash = hashToken(raw);
|
|
111
|
+
const operator = await db.get('SELECT value FROM meta WHERE key=?', 'operator_token_hash');
|
|
112
|
+
if (operator && hash === operator.value)
|
|
113
|
+
return { name: OPERATOR_ACTOR, role: 'admin', projects: null };
|
|
114
|
+
const row = await db.get('SELECT * FROM api_tokens WHERE token_hash=?', hash);
|
|
115
|
+
if (!row || row.revoked_at || (row.expires_at && row.expires_at <= now()))
|
|
116
|
+
return null;
|
|
117
|
+
// Best effort and throttled: a slow or failing bookkeeping write must never fail or delay the request.
|
|
118
|
+
if (!row.last_used_at || Date.parse(row.last_used_at) < Date.now() - LAST_USED_INTERVAL_MS)
|
|
119
|
+
void db.run('UPDATE api_tokens SET last_used_at=? WHERE id=?', now(), row.id).catch(() => { });
|
|
120
|
+
return { name: row.name, role: row.role, projects: row.projects_json ? JSON.parse(row.projects_json) : null };
|
|
121
|
+
}
|
|
122
|
+
export const bodyProject = req => String(req.body?.project || 'site');
|
|
123
|
+
export const queryProject = req => String(req.query.project || 'site');
|
|
124
|
+
const FORBIDDEN_PROJECT = 'forbidden: this token may not access that project';
|
|
125
|
+
/** Throws 403 unless the actor may act on `project`. Used where the project is only known after the body is parsed. */
|
|
126
|
+
export function assertProject(actor, project) {
|
|
127
|
+
if (actor.projects && !actor.projects.includes(project))
|
|
128
|
+
throw statusError(403, FORBIDDEN_PROJECT);
|
|
129
|
+
}
|
|
130
|
+
/** Builds the request guard: authenticate (401), check the role (403), then check project scope (403). */
|
|
131
|
+
export function createGuard(db) {
|
|
132
|
+
return (required, resolve) => async (req, res, next) => {
|
|
133
|
+
try {
|
|
134
|
+
const actor = await authenticate(db, (req.headers.authorization || '').replace(/^Bearer /, ''));
|
|
135
|
+
if (!actor) {
|
|
136
|
+
res.status(401).json({ error: 'unauthorized' });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (!hasRole(actor, required)) {
|
|
140
|
+
res.status(403).json({ error: `forbidden: this action requires the ${required} role` });
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (resolve) {
|
|
144
|
+
const project = await resolve(req);
|
|
145
|
+
if (project === null && !hasRole(actor, 'admin')) {
|
|
146
|
+
res.status(403).json({ error: 'forbidden: this action requires the admin role' });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (actor.projects && (project === undefined || project === null || !actor.projects.includes(project))) {
|
|
150
|
+
res.status(403).json({ error: FORBIDDEN_PROJECT });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
req.actor = actor;
|
|
155
|
+
next();
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
next(error);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
/** Appends a control-originated audit event naming the acting token. */
|
|
163
|
+
export async function recordEvent(db, event) {
|
|
164
|
+
await db.run('INSERT INTO events(id,project_id,deployment_id,node_id,level,type,message,metadata_json,created_at,actor) VALUES(?,?,?,?,?,?,?,?,?,?)', id('evt'), event.project ?? null, event.deploymentId ?? null, null, 'info', event.type, event.message, JSON.stringify(event.metadata ?? {}), now(), event.actor);
|
|
165
|
+
}
|
package/dist/util.d.ts
CHANGED
|
@@ -10,3 +10,6 @@ export declare function safeJoin(base: string, child: string): string;
|
|
|
10
10
|
export declare function renderTemplate(str: string, vars: Record<string, string>): string;
|
|
11
11
|
export declare function loadTemplate(templatesDir: string, name: string, vars: Record<string, string>): Promise<string>;
|
|
12
12
|
export declare const errMessage: (e: unknown) => string;
|
|
13
|
+
/** Parses `500ms` / `2s` / `1m` or a millisecond number; `fallback` when it does not parse. */
|
|
14
|
+
export declare function parseDuration(value: string | number, fallback?: number): number;
|
|
15
|
+
export declare const isDuration: (value: unknown) => boolean;
|
package/dist/util.js
CHANGED
|
@@ -22,3 +22,14 @@ export function renderTemplate(str, vars) { return str.replace(/\{\{(\w+)\}\}/g,
|
|
|
22
22
|
throw new Error(`missing template var ${key}`); return vars[key]; }); }
|
|
23
23
|
export async function loadTemplate(templatesDir, name, vars) { return renderTemplate(await fs.readFile(path.join(templatesDir, name), 'utf8'), vars); }
|
|
24
24
|
export const errMessage = (e) => (e instanceof Error ? e.message : String(e));
|
|
25
|
+
/** Parses `500ms` / `2s` / `1m` or a millisecond number; `fallback` when it does not parse. */
|
|
26
|
+
export function parseDuration(value, fallback = 2000) {
|
|
27
|
+
if (typeof value === 'number')
|
|
28
|
+
return value;
|
|
29
|
+
const m = String(value).match(/^(\d+)(ms|s|m)?$/);
|
|
30
|
+
if (!m)
|
|
31
|
+
return fallback;
|
|
32
|
+
const n = Number(m[1]);
|
|
33
|
+
return m[2] === 'm' ? n * 60000 : m[2] === 'ms' ? n : n * 1000;
|
|
34
|
+
}
|
|
35
|
+
export const isDuration = (value) => (typeof value === 'number' && Number.isFinite(value) && value >= 0) || (typeof value === 'string' && /^\d+(ms|s|m)?$/.test(value));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wpelevator/tug",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Portable VPS deployments with Docker Compose",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -32,7 +32,10 @@
|
|
|
32
32
|
"test:smoke:dist": "npm run build && CLI_ENTRY=dist/cli.js node --test src/cli.templates.test.ts",
|
|
33
33
|
"changeset": "changeset",
|
|
34
34
|
"version-packages": "changeset version && npm install --package-lock-only --ignore-scripts",
|
|
35
|
-
"release": "changeset publish"
|
|
35
|
+
"release": "changeset publish",
|
|
36
|
+
"test:integration:rollout": "node tools/scripts/integration-rollout.js",
|
|
37
|
+
"test:integration:no-registry": "node tools/scripts/integration-no-registry.js",
|
|
38
|
+
"test:smoke:image": "node tools/scripts/smoke-runtime-image.js"
|
|
36
39
|
},
|
|
37
40
|
"dependencies": {
|
|
38
41
|
"commander": "^12.1.0",
|