@wpelevator/tug 0.2.0 → 0.3.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/dist/tokens.js CHANGED
@@ -17,6 +17,7 @@ const LAST_USED_INTERVAL_MS = 60_000;
17
17
  * credential check (the registry hook) and are not behind the token guard. docs/access-control.md mirrors this table.
18
18
  */
19
19
  export const ROUTE_ROLES = {
20
+ 'GET /api/v1/live': 'deploy',
20
21
  'GET /api/v1/status': 'read',
21
22
  'GET /api/v1/events': 'read',
22
23
  'GET /api/v1/projects/:project': 'read',
@@ -27,6 +28,7 @@ export const ROUTE_ROLES = {
27
28
  'POST /api/v1/deployments': 'deploy',
28
29
  'POST /api/v1/deployments/:id/cancel': 'deploy',
29
30
  'POST /api/v1/rollback': 'deploy',
31
+ 'POST /api/v1/scale': 'deploy',
30
32
  'POST /api/v1/restart': 'deploy',
31
33
  'POST /api/v1/logs': 'deploy',
32
34
  'GET /api/v1/commands/:id': 'deploy',
@@ -40,6 +42,7 @@ export const ROUTE_ROLES = {
40
42
  'POST /api/v1/nodes/:name/revoke-registry-token': 'admin',
41
43
  'POST /api/v1/nodes/:name/rotate-wg-key': 'admin',
42
44
  'POST /api/v1/nodes/:name/ingress': 'admin',
45
+ 'DELETE /api/v1/nodes/:name': 'admin',
43
46
  'POST /api/v1/control/backups/run': 'admin',
44
47
  'POST /api/v1/control/restore': 'admin',
45
48
  'GET /api/v1/diagnostics': 'admin',
@@ -47,7 +50,7 @@ export const ROUTE_ROLES = {
47
50
  'POST /api/v1/tokens': 'admin',
48
51
  'GET /api/v1/tokens': 'admin',
49
52
  'DELETE /api/v1/tokens/:name': 'admin',
50
- 'POST /api/v1/registry/auth': 'public'
53
+ 'POST /api/v1/registry/auth': 'public',
51
54
  };
52
55
  const statusError = (status, message) => Object.assign(new Error(message), { status });
53
56
  export const isRole = (value) => typeof value === 'string' && ROLES.includes(value);
@@ -64,7 +67,10 @@ export function parseTokenRequest(body) {
64
67
  throw statusError(400, `role must be one of ${ROLES.join(', ')}`);
65
68
  let scope = null;
66
69
  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)))
70
+ if (!Array.isArray(projects) ||
71
+ !projects.length ||
72
+ projects.length > 100 ||
73
+ projects.some((p) => typeof p !== 'string' || !PROJECT_NAME_PATTERN.test(p)))
68
74
  throw statusError(400, 'projects must be a nonempty list of valid project names, or omitted for all projects');
69
75
  if (role === 'admin')
70
76
  throw statusError(400, 'admin tokens manage the whole control plane and cannot be limited to projects');
@@ -72,13 +78,25 @@ export function parseTokenRequest(body) {
72
78
  }
73
79
  let expiresAt = null;
74
80
  if (expiresInSeconds !== undefined && expiresInSeconds !== null) {
75
- if (typeof expiresInSeconds !== 'number' || !Number.isSafeInteger(expiresInSeconds) || expiresInSeconds < 1 || expiresInSeconds > 10 * 365 * 86400)
81
+ if (typeof expiresInSeconds !== 'number' ||
82
+ !Number.isSafeInteger(expiresInSeconds) ||
83
+ expiresInSeconds < 1 ||
84
+ expiresInSeconds > 10 * 365 * 86400)
76
85
  throw statusError(400, 'expiresInSeconds must be a positive integer of at most ten years');
77
86
  expiresAt = new Date(Date.now() + expiresInSeconds * 1000).toISOString();
78
87
  }
79
88
  return { name, role, projects: scope, expiresAt };
80
89
  }
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 });
90
+ const info = (row) => ({
91
+ name: row.name,
92
+ role: row.role,
93
+ projects: row.projects_json ? JSON.parse(row.projects_json) : null,
94
+ createdAt: row.created_at,
95
+ createdBy: row.created_by,
96
+ lastUsedAt: row.last_used_at,
97
+ expiresAt: row.expires_at,
98
+ revokedAt: row.revoked_at,
99
+ });
82
100
  /** Creates a token. The plaintext is returned once; only its hash is stored. */
83
101
  export async function createToken(db, request, createdBy) {
84
102
  const value = token(TOKEN_PREFIX);
@@ -90,7 +108,10 @@ export async function createToken(db, request, createdBy) {
90
108
  throw statusError(409, `token name ${request.name} is already used (revoked tokens keep their names for the audit trail)`);
91
109
  throw e;
92
110
  }
93
- return { ...info((await db.get('SELECT * FROM api_tokens WHERE name=?', request.name))), token: value };
111
+ return {
112
+ ...info((await db.get('SELECT * FROM api_tokens WHERE name=?', request.name))),
113
+ token: value,
114
+ };
94
115
  }
95
116
  export async function listTokens(db) {
96
117
  return (await db.all('SELECT * FROM api_tokens ORDER BY created_at, name')).map(info);
@@ -117,10 +138,14 @@ export async function authenticate(db, raw) {
117
138
  // Best effort and throttled: a slow or failing bookkeeping write must never fail or delay the request.
118
139
  if (!row.last_used_at || Date.parse(row.last_used_at) < Date.now() - LAST_USED_INTERVAL_MS)
119
140
  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 };
141
+ return {
142
+ name: row.name,
143
+ role: row.role,
144
+ projects: row.projects_json ? JSON.parse(row.projects_json) : null,
145
+ };
121
146
  }
122
- export const bodyProject = req => String(req.body?.project || 'site');
123
- export const queryProject = req => String(req.query.project || 'site');
147
+ export const bodyProject = (req) => String(req.body?.project || 'site');
148
+ export const queryProject = (req) => String(req.query.project || 'site');
124
149
  const FORBIDDEN_PROJECT = 'forbidden: this token may not access that project';
125
150
  /** Throws 403 unless the actor may act on `project`. Used where the project is only known after the body is parsed. */
126
151
  export function assertProject(actor, project) {
@@ -146,7 +171,8 @@ export function createGuard(db) {
146
171
  res.status(403).json({ error: 'forbidden: this action requires the admin role' });
147
172
  return;
148
173
  }
149
- if (actor.projects && (project === undefined || project === null || !actor.projects.includes(project))) {
174
+ if (actor.projects &&
175
+ (project === undefined || project === null || !actor.projects.includes(project))) {
150
176
  res.status(403).json({ error: FORBIDDEN_PROJECT });
151
177
  return;
152
178
  }
package/dist/util.d.ts CHANGED
@@ -13,3 +13,10 @@ export declare const errMessage: (e: unknown) => string;
13
13
  /** Parses `500ms` / `2s` / `1m` or a millisecond number; `fallback` when it does not parse. */
14
14
  export declare function parseDuration(value: string | number, fallback?: number): number;
15
15
  export declare const isDuration: (value: unknown) => boolean;
16
+ /**
17
+ * True for an IP address that is routable on the internet: anything outside loopback, private
18
+ * (RFC 1918, IPv6 ULA), link-local, CGNAT (`100.64.0.0/10`) and the unspecified address. The
19
+ * unspecified addresses `0.0.0.0` and `::` count as public, since listening there means every
20
+ * interface. Hostnames and other non-IP strings return false.
21
+ */
22
+ export declare function isPublicAddress(address: string): boolean;
package/dist/util.js CHANGED
@@ -1,26 +1,48 @@
1
1
  import crypto from 'node:crypto';
2
2
  import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
+ import { isIP } from 'node:net';
4
5
  export const now = () => new Date().toISOString();
5
6
  export const id = (prefix) => `${prefix}_${crypto.randomBytes(8).toString('hex')}`;
6
7
  export const token = (prefix = 'tug') => `${prefix}_${crypto.randomBytes(24).toString('base64url')}`;
7
8
  export const hashToken = (value) => crypto.createHash('sha256').update(value).digest('hex');
8
- export async function ensureDir(dir) { await fs.mkdir(dir, { recursive: true }); }
9
- export async function exists(p) { try {
10
- await fs.access(p);
11
- return true;
9
+ export async function ensureDir(dir) {
10
+ await fs.mkdir(dir, { recursive: true });
11
+ }
12
+ export async function exists(p) {
13
+ try {
14
+ await fs.access(p);
15
+ return true;
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ }
21
+ export function requireEnv(name, fallback) {
22
+ const v = process.env[name] ?? fallback;
23
+ if (!v)
24
+ throw new Error(`Missing ${name}`);
25
+ return v;
26
+ }
27
+ export function projectRoot() {
28
+ return process.cwd();
29
+ }
30
+ export function safeJoin(base, child) {
31
+ const out = path.resolve(base, child);
32
+ if (!out.startsWith(path.resolve(base)))
33
+ throw new Error('unsafe path');
34
+ return out;
35
+ }
36
+ export function renderTemplate(str, vars) {
37
+ return str.replace(/\{\{(\w+)\}\}/g, (m, key) => {
38
+ if (!(key in vars))
39
+ throw new Error(`missing template var ${key}`);
40
+ return vars[key];
41
+ });
42
+ }
43
+ export async function loadTemplate(templatesDir, name, vars) {
44
+ return renderTemplate(await fs.readFile(path.join(templatesDir, name), 'utf8'), vars);
12
45
  }
13
- catch {
14
- return false;
15
- } }
16
- export function requireEnv(name, fallback) { const v = process.env[name] ?? fallback; if (!v)
17
- throw new Error(`Missing ${name}`); return v; }
18
- export function projectRoot() { return process.cwd(); }
19
- export function safeJoin(base, child) { const out = path.resolve(base, child); if (!out.startsWith(path.resolve(base)))
20
- throw new Error('unsafe path'); return out; }
21
- export function renderTemplate(str, vars) { return str.replace(/\{\{(\w+)\}\}/g, (m, key) => { if (!(key in vars))
22
- throw new Error(`missing template var ${key}`); return vars[key]; }); }
23
- export async function loadTemplate(templatesDir, name, vars) { return renderTemplate(await fs.readFile(path.join(templatesDir, name), 'utf8'), vars); }
24
46
  export const errMessage = (e) => (e instanceof Error ? e.message : String(e));
25
47
  /** Parses `500ms` / `2s` / `1m` or a millisecond number; `fallback` when it does not parse. */
26
48
  export function parseDuration(value, fallback = 2000) {
@@ -32,4 +54,39 @@ export function parseDuration(value, fallback = 2000) {
32
54
  const n = Number(m[1]);
33
55
  return m[2] === 'm' ? n * 60000 : m[2] === 'ms' ? n : n * 1000;
34
56
  }
35
- export const isDuration = (value) => (typeof value === 'number' && Number.isFinite(value) && value >= 0) || (typeof value === 'string' && /^\d+(ms|s|m)?$/.test(value));
57
+ export const isDuration = (value) => (typeof value === 'number' && Number.isFinite(value) && value >= 0) ||
58
+ (typeof value === 'string' && /^\d+(ms|s|m)?$/.test(value));
59
+ /**
60
+ * True for an IP address that is routable on the internet: anything outside loopback, private
61
+ * (RFC 1918, IPv6 ULA), link-local, CGNAT (`100.64.0.0/10`) and the unspecified address. The
62
+ * unspecified addresses `0.0.0.0` and `::` count as public, since listening there means every
63
+ * interface. Hostnames and other non-IP strings return false.
64
+ */
65
+ export function isPublicAddress(address) {
66
+ const ip = address.replace(/^\[|\]$/g, '');
67
+ const kind = isIP(ip);
68
+ if (kind === 4) {
69
+ const [a, b] = ip.split('.').map(Number);
70
+ if (ip === '0.0.0.0')
71
+ return true;
72
+ return !(a === 10 ||
73
+ a === 127 ||
74
+ (a === 172 && b >= 16 && b <= 31) ||
75
+ (a === 192 && b === 168) ||
76
+ (a === 169 && b === 254) ||
77
+ (a === 100 && b >= 64 && b <= 127));
78
+ }
79
+ if (kind === 6) {
80
+ const lower = ip.toLowerCase();
81
+ if (lower === '::')
82
+ return true;
83
+ if (lower === '::1')
84
+ return false;
85
+ const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
86
+ if (mapped)
87
+ return isPublicAddress(mapped[1]);
88
+ const first = Number.parseInt(lower.split(':')[0] || '0', 16);
89
+ return !((first & 0xfe00) === 0xfc00 || (first & 0xffc0) === 0xfe80);
90
+ }
91
+ return false;
92
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wpelevator/tug",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Portable VPS deployments with Docker Compose",
5
5
  "repository": {
6
6
  "type": "git",
@@ -24,8 +24,8 @@
24
24
  "typecheck": "tsc -p tsconfig.json --noEmit",
25
25
  "prepack": "npm run build",
26
26
  "test": "node --test 'src/**/*.test.ts'",
27
- "lint": "eslint .",
28
- "format": "eslint . --fix",
27
+ "lint": "eslint . && prettier --check .",
28
+ "format": "prettier --write . && eslint . --fix",
29
29
  "test:integration": "bash tools/scripts/integration-test.sh",
30
30
  "test:integration:network": "bash tools/scripts/integration-network-test.sh",
31
31
  "test:integration:agent": "node --test 'tests/integration/**/*.test.ts'",
@@ -45,7 +45,8 @@
45
45
  "multer": "^2.2.0",
46
46
  "sqlite": "^5.1.1",
47
47
  "sqlite3": "^5.1.7",
48
- "tar": "^7.4.3"
48
+ "tar": "^7.4.3",
49
+ "ws": "^8.22.1"
49
50
  },
50
51
  "devDependencies": {
51
52
  "@changesets/cli": "^3.0.3",
@@ -54,8 +55,11 @@
54
55
  "@types/js-yaml": "^4.0.9",
55
56
  "@types/multer": "^2.3.0",
56
57
  "@types/node": "^22.20.5",
58
+ "@types/ws": "^8.18.2",
57
59
  "eslint": "^10.5.0",
60
+ "eslint-config-prettier": "^10.1.8",
58
61
  "globals": "^17.7.0",
62
+ "prettier": "^3.9.10",
59
63
  "typescript": "^6.0.3",
60
64
  "typescript-eslint": "^8.72.1"
61
65
  },