@wpelevator/tug 0.4.0 → 0.5.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.
@@ -0,0 +1,20 @@
1
+ /** The portable subset shared by synchronous SQLite runtimes. Only anonymous ? bindings are supported. */
2
+ export type SqlValue = string | number | bigint | null | Uint8Array;
3
+ export type SqlRow = Record<string, SqlValue>;
4
+ export interface Db {
5
+ get<T = SqlRow>(sql: string, ...values: SqlValue[]): T | undefined;
6
+ all<T = SqlRow>(sql: string, ...values: SqlValue[]): T[];
7
+ run(sql: string, ...values: SqlValue[]): {
8
+ changes: number;
9
+ };
10
+ exec(sql: string): void;
11
+ close(): void;
12
+ }
13
+ export type ConnectDb = (filename: string, options: {
14
+ readOnly: boolean;
15
+ }) => Db;
16
+ export declare class DbError extends Error {
17
+ readonly kind: 'unique' | 'busy';
18
+ constructor(kind: DbError['kind'], cause: Error);
19
+ }
20
+ export declare function isUniqueViolation(error: unknown): boolean;
@@ -0,0 +1,11 @@
1
+ export class DbError extends Error {
2
+ kind;
3
+ constructor(kind, cause) {
4
+ super(cause.message, { cause });
5
+ this.name = 'DbError';
6
+ this.kind = kind;
7
+ }
8
+ }
9
+ export function isUniqueViolation(error) {
10
+ return error instanceof DbError && error.kind === 'unique';
11
+ }
@@ -0,0 +1,3 @@
1
+ import type { ConnectDb } from './db-driver.ts';
2
+ /** This module is the only runtime-specific adapter; replace it at build time for a future Bun bundle. */
3
+ export declare const connectNodeDb: ConnectDb;
@@ -0,0 +1,60 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { DbError } from "./db-driver.js";
3
+ function sqliteCall(operation) {
4
+ try {
5
+ return operation();
6
+ }
7
+ catch (error) {
8
+ if (error instanceof Error && 'errcode' in error) {
9
+ const code = Number(error.errcode);
10
+ if (code === 1555 || code === 2067)
11
+ throw new DbError('unique', error);
12
+ if ((code & 255) === 5 || (code & 255) === 6)
13
+ throw new DbError('busy', error);
14
+ }
15
+ throw error;
16
+ }
17
+ }
18
+ /** Count anonymous placeholders outside SQL literals, quoted identifiers and comments. */
19
+ function checkBindings(sql, values) {
20
+ const tokens = sql.match(/'(?:''|[^'])*'|"(?:""|[^"])*"|`(?:``|[^`])*`|\[[^\]]*\]|--[^\r\n]*|\/\*[\s\S]*?\*\/|\?\d*|[:@$][\w]+/g) ?? [];
21
+ const parameters = tokens.filter((token) => /^[?:@$]/.test(token));
22
+ if (parameters.some((token) => token !== '?'))
23
+ throw new TypeError('Only anonymous ? SQL parameters are supported');
24
+ if (parameters.length !== values.length)
25
+ throw new TypeError(`Expected ${parameters.length} SQL bindings, received ${values.length}`);
26
+ for (const value of values) {
27
+ if (value !== null &&
28
+ typeof value !== 'string' &&
29
+ typeof value !== 'bigint' &&
30
+ !(typeof value === 'number' && Number.isFinite(value)) &&
31
+ !(value instanceof Uint8Array))
32
+ throw new TypeError('SQL bindings must be strings, finite numbers, bigints, null or Uint8Array');
33
+ }
34
+ }
35
+ /** This module is the only runtime-specific adapter; replace it at build time for a future Bun bundle. */
36
+ export const connectNodeDb = (filename, { readOnly }) => {
37
+ const database = sqliteCall(() => new DatabaseSync(filename, { readOnly }));
38
+ const prepare = (sql, values) => {
39
+ checkBindings(sql, values);
40
+ return database.prepare(sql);
41
+ };
42
+ return {
43
+ get: (sql, ...values) => sqliteCall(() => {
44
+ const row = prepare(sql, values).get(...values);
45
+ return row === undefined ? undefined : { ...row };
46
+ }),
47
+ all: (sql, ...values) => sqliteCall(() => prepare(sql, values)
48
+ .all(...values)
49
+ .map((row) => ({ ...row }))),
50
+ run: (sql, ...values) => sqliteCall(() => {
51
+ const { changes } = prepare(sql, values).run(...values);
52
+ const count = Number(changes);
53
+ if (!Number.isSafeInteger(count))
54
+ throw new RangeError('SQL change count exceeds the safe integer range');
55
+ return { changes: count };
56
+ }),
57
+ exec: (sql) => sqliteCall(() => database.exec(sql)),
58
+ close: () => sqliteCall(() => database.close()),
59
+ };
60
+ };
package/dist/db.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- import sqlite3 from 'sqlite3';
2
- import type { Database } from 'sqlite';
3
- export type Db = Database<sqlite3.Database, sqlite3.Statement>;
1
+ import type { ConnectDb, Db } from './db-driver.ts';
2
+ export type { Db } from './db-driver.ts';
3
+ export declare const openDb: (filename: string) => Promise<Db>, openAgentDb: (filename: string) => Promise<Db>, openSnapshotDb: (filename: string) => Db;
4
4
  export interface ProjectRow {
5
5
  id: string;
6
6
  name: string;
@@ -19,7 +19,6 @@ export interface NodeRow {
19
19
  status: string;
20
20
  labels_json: string | null;
21
21
  agent_token_hash: string | null;
22
- registry_token_hash: string | null;
23
22
  last_seen_at: string | null;
24
23
  created_at: string;
25
24
  updated_at: string;
@@ -120,5 +119,9 @@ export interface AppliedStateRow {
120
119
  service_states_json: string;
121
120
  secret_versions_json: string;
122
121
  }
123
- export declare function openDb(filename: string): Promise<Db>;
124
- export declare function openAgentDb(filename: string): Promise<Db>;
122
+ /** Shared policy for every driver, including read-only restore validation. */
123
+ export declare function databaseAccess(connect: ConnectDb): {
124
+ openDb: (filename: string) => Promise<Db>;
125
+ openAgentDb: (filename: string) => Promise<Db>;
126
+ openSnapshotDb: (filename: string) => Db;
127
+ };
package/dist/db.js CHANGED
@@ -1,19 +1,38 @@
1
- import sqlite3 from 'sqlite3';
2
- import { open } from 'sqlite';
1
+ import { connectNodeDb } from "./db-node.js";
3
2
  import path from 'node:path';
4
3
  import { ensureDir, now } from "./util.js";
5
- export async function openDb(filename) {
6
- await ensureDir(path.dirname(filename));
7
- const db = await open({ filename, driver: sqlite3.Database });
8
- await db.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;');
9
- await migrate(db);
10
- return db;
4
+ // One composition point for all writable and snapshot connections.
5
+ export const { openDb, openAgentDb, openSnapshotDb } = databaseAccess(connectNodeDb);
6
+ function configure(db, writable, initialize) {
7
+ try {
8
+ db.exec('PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 1000;');
9
+ if (writable)
10
+ db.exec('PRAGMA journal_mode = WAL;');
11
+ initialize?.(db);
12
+ return db;
13
+ }
14
+ catch (error) {
15
+ try {
16
+ db.close();
17
+ }
18
+ catch {
19
+ /* Preserve the initialization failure. */
20
+ }
21
+ throw error;
22
+ }
11
23
  }
12
- export async function openAgentDb(filename) {
13
- await ensureDir(path.dirname(filename));
14
- const db = await open({ filename, driver: sqlite3.Database });
15
- await db.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;');
16
- await db.exec(`
24
+ /** Shared policy for every driver, including read-only restore validation. */
25
+ export function databaseAccess(connect) {
26
+ function openSnapshotDb(filename) {
27
+ return configure(connect(filename, { readOnly: true }), false);
28
+ }
29
+ async function openDb(filename) {
30
+ await ensureDir(path.dirname(filename));
31
+ return configure(connect(filename, { readOnly: false }), true, initializeControl);
32
+ }
33
+ async function openAgentDb(filename) {
34
+ await ensureDir(path.dirname(filename));
35
+ return configure(connect(filename, { readOnly: false }), true, (db) => db.exec(`
17
36
  CREATE TABLE IF NOT EXISTS applied_state(
18
37
  project TEXT PRIMARY KEY,
19
38
  version TEXT,
@@ -27,14 +46,15 @@ CREATE TABLE IF NOT EXISTS event_state(
27
46
  fingerprint TEXT NOT NULL,
28
47
  phase TEXT NOT NULL
29
48
  );
30
- `);
31
- return db;
49
+ `));
50
+ }
51
+ return { openDb, openAgentDb, openSnapshotDb };
32
52
  }
33
- async function migrate(db) {
34
- await db.exec(`
53
+ function initializeControl(db) {
54
+ db.exec(`
35
55
  CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);
36
56
  CREATE TABLE IF NOT EXISTS projects(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, current_deployment_id TEXT, created_at TEXT, updated_at TEXT);
37
- CREATE TABLE IF NOT EXISTS nodes(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, address TEXT, public_address TEXT, overlay_ip TEXT UNIQUE, ingress INTEGER NOT NULL DEFAULT 0, ingress_status_json TEXT, status TEXT, labels_json TEXT, agent_token_hash TEXT, registry_token_hash TEXT, last_seen_at TEXT, created_at TEXT, updated_at TEXT);
57
+ CREATE TABLE IF NOT EXISTS nodes(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, address TEXT, public_address TEXT, overlay_ip TEXT UNIQUE, ingress INTEGER NOT NULL DEFAULT 0, ingress_status_json TEXT, status TEXT, labels_json TEXT, agent_token_hash TEXT, last_seen_at TEXT, created_at TEXT, updated_at TEXT);
38
58
  CREATE TABLE IF NOT EXISTS wg_peers(node_id TEXT PRIMARY KEY, public_key TEXT NOT NULL UNIQUE, overlay_ip TEXT NOT NULL, endpoint TEXT, allowed_ips TEXT NOT NULL, updated_at TEXT);
39
59
  CREATE TABLE IF NOT EXISTS enroll_tokens(token_hash TEXT PRIMARY KEY, name TEXT, uses_remaining INTEGER, expires_at TEXT, created_at TEXT);
40
60
  CREATE TABLE IF NOT EXISTS deployments(id TEXT PRIMARY KEY, project_id TEXT, version TEXT, state TEXT, created_at TEXT, updated_at TEXT, error_message TEXT, actor TEXT);
@@ -51,13 +71,7 @@ CREATE TABLE IF NOT EXISTS api_tokens(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT
51
71
  CREATE INDEX IF NOT EXISTS events_by_time ON events(created_at);
52
72
  CREATE TABLE IF NOT EXISTS locks(name TEXT PRIMARY KEY, owner TEXT, expires_at TEXT, created_at TEXT);
53
73
  `);
54
- try {
55
- await db.exec('ALTER TABLE nodes ADD COLUMN labels_json TEXT');
56
- }
57
- catch {
58
- /* column already exists */
59
- }
60
- const row = await db.get('SELECT value FROM meta WHERE key = ?', 'schema_created_at');
74
+ const row = db.get('SELECT value FROM meta WHERE key = ?', 'schema_created_at');
61
75
  if (!row)
62
- await db.run('INSERT INTO meta(key,value) VALUES(?,?)', 'schema_created_at', now());
76
+ db.run('INSERT INTO meta(key,value) VALUES(?,?)', 'schema_created_at', now());
63
77
  }
@@ -19,8 +19,14 @@ export const isNotFound = (error) => error instanceof DockerError && error.statu
19
19
  /** Splits a reference into `fromImage` and `tag` for `POST /images/create`. */
20
20
  export function splitImageRef(ref) {
21
21
  const digest = ref.indexOf('@');
22
- if (digest >= 0)
23
- return { fromImage: ref.slice(0, digest), tag: ref.slice(digest + 1) };
22
+ if (digest >= 0) {
23
+ const name = ref.slice(0, digest);
24
+ const colon = name.lastIndexOf(':');
25
+ return {
26
+ fromImage: colon > name.lastIndexOf('/') ? name.slice(0, colon) : name,
27
+ tag: ref.slice(digest + 1),
28
+ };
29
+ }
24
30
  const colon = ref.lastIndexOf(':');
25
31
  if (colon > ref.lastIndexOf('/'))
26
32
  return { fromImage: ref.slice(0, colon), tag: ref.slice(colon + 1) };
@@ -396,8 +402,14 @@ export class DockerEngine {
396
402
  const { fromImage, tag } = splitImageRef(ref);
397
403
  const res = await this.request('POST', '/images/create', {
398
404
  query: { fromImage, tag },
405
+ // Docker uses Go's padded base64.URLEncoding; Node's base64url encoding omits required '='.
399
406
  headers: auth
400
- ? { 'x-registry-auth': Buffer.from(JSON.stringify(auth)).toString('base64url') }
407
+ ? {
408
+ 'x-registry-auth': Buffer.from(JSON.stringify(auth))
409
+ .toString('base64')
410
+ .replaceAll('+', '-')
411
+ .replaceAll('/', '_'),
412
+ }
401
413
  : {},
402
414
  });
403
415
  await this.progress(res, `pull ${ref}`);
@@ -421,6 +421,10 @@ export interface StatusReport {
421
421
  ingress?: IngressStatus;
422
422
  }
423
423
  export interface SecretsResponse {
424
+ registryAuth?: Record<string, {
425
+ username: string;
426
+ password: string;
427
+ }>;
424
428
  secrets: Record<string, string>;
425
429
  versions: Record<string, number>;
426
430
  }
@@ -447,7 +451,6 @@ export interface NodeInfo {
447
451
  export interface RegisterResponse {
448
452
  node: NodeInfo;
449
453
  agentToken: string;
450
- registryToken: string;
451
454
  }
452
455
  /** Body of `POST /agent/v1/enroll`. */
453
456
  export interface EnrollRequest {
@@ -461,16 +464,13 @@ export interface EnrollRequest {
461
464
  labels?: string[];
462
465
  }
463
466
  /** Response of `POST /agent/v1/enroll`. */
464
- export interface EnrollResponse extends RegisterResponse {
465
- registry: string;
466
- }
467
+ export type EnrollResponse = RegisterResponse;
467
468
  /** Response of `POST /api/v1/nodes/enroll-token`. */
468
469
  export interface EnrollTokenResponse {
469
470
  enrollToken: string;
470
471
  name: string | null;
471
472
  usesRemaining: number;
472
473
  expiresAt: string;
473
- registry: string;
474
474
  }
475
475
  /** The base64url-encoded JSON payload of TUG_JOIN_KEY. */
476
476
  export interface JoinKey {
@@ -0,0 +1,17 @@
1
+ export declare const REGISTRY_AUTH_SECRET = "TUG_REGISTRY_AUTH";
2
+ export interface RegistryCredential {
3
+ username: string;
4
+ password: string;
5
+ }
6
+ export type RegistryCredentials = Record<string, RegistryCredential>;
7
+ /** A project secret containing registry-host -> username/password entries; never a desired-state field. */
8
+ export declare function parseRegistryCredentials(value?: string): RegistryCredentials;
9
+ export declare function imageLocation(image: string): {
10
+ host: string;
11
+ repository: string;
12
+ tag: string;
13
+ digest: string;
14
+ name: string;
15
+ };
16
+ /** Pin the complete index, so amd64 and arm64 agents select from the same immutable manifest. */
17
+ export declare function resolveImage(image: string, credentials?: RegistryCredentials, request?: typeof fetch): Promise<string>;
@@ -0,0 +1,133 @@
1
+ import { createHash } from 'node:crypto';
2
+ export const REGISTRY_AUTH_SECRET = 'TUG_REGISTRY_AUTH';
3
+ /** A project secret containing registry-host -> username/password entries; never a desired-state field. */
4
+ export function parseRegistryCredentials(value) {
5
+ if (!value)
6
+ return {};
7
+ try {
8
+ const entries = JSON.parse(value);
9
+ if (!entries || typeof entries !== 'object' || Array.isArray(entries))
10
+ throw new Error();
11
+ const credentials = {};
12
+ for (const [host, auth] of Object.entries(entries)) {
13
+ if (!/^[a-zA-Z0-9.:[\]-]+$/.test(host) ||
14
+ !auth ||
15
+ typeof auth !== 'object' ||
16
+ typeof auth.username !== 'string' ||
17
+ typeof auth.password !== 'string')
18
+ throw new Error();
19
+ credentials[host] = { username: auth.username, password: auth.password };
20
+ }
21
+ return credentials;
22
+ }
23
+ catch {
24
+ throw new Error(`${REGISTRY_AUTH_SECRET} must be a JSON object mapping registry hosts to username/password objects`);
25
+ }
26
+ }
27
+ export function imageLocation(image) {
28
+ const match = /^(?<name>[^@\s]+?)(?:@(?<digest>sha256:[a-f0-9]{64}))?$/.exec(image);
29
+ if (!match?.groups)
30
+ throw new Error('invalid image reference');
31
+ const { name, digest } = match.groups;
32
+ const slash = name.indexOf('/');
33
+ const qualified = slash >= 0 && /[.:]|^localhost$/.test(name.slice(0, slash));
34
+ let host = qualified ? name.slice(0, slash) : 'docker.io';
35
+ if (host === 'index.docker.io')
36
+ host = 'docker.io';
37
+ let repository = qualified ? name.slice(slash + 1) : name;
38
+ const colon = repository.lastIndexOf(':');
39
+ const tag = colon >= 0 ? repository.slice(colon + 1) : 'latest';
40
+ if (colon >= 0)
41
+ repository = repository.slice(0, colon);
42
+ if (host === 'docker.io' && !repository.includes('/'))
43
+ repository = `library/${repository}`;
44
+ if (!/^[a-z0-9]+(?:[._-]+[a-z0-9]+)*(?:\/[a-z0-9]+(?:[._-]+[a-z0-9]+)*)*$/.test(repository) ||
45
+ !/^[\w][\w.-]{0,127}$/.test(tag) ||
46
+ !/^[a-zA-Z0-9.:[\]-]+$/.test(host))
47
+ throw new Error('invalid image reference');
48
+ return { host, repository, tag, digest, name };
49
+ }
50
+ const accept = [
51
+ 'application/vnd.oci.image.index.v1+json',
52
+ 'application/vnd.docker.distribution.manifest.list.v2+json',
53
+ 'application/vnd.oci.image.manifest.v1+json',
54
+ 'application/vnd.docker.distribution.manifest.v2+json',
55
+ ].join(', ');
56
+ /** Pin the complete index, so amd64 and arm64 agents select from the same immutable manifest. */
57
+ export async function resolveImage(image, credentials = {}, request = fetch) {
58
+ const location = imageLocation(image);
59
+ if (location.digest)
60
+ return image;
61
+ const { host, repository, tag, name } = location;
62
+ const endpoint = host === 'docker.io' ? 'registry-1.docker.io' : host;
63
+ const url = `https://${endpoint}/v2/${repository}/manifests/${tag}`;
64
+ const auth = credentials[host];
65
+ const basic = auth
66
+ ? `Basic ${Buffer.from(`${auth.username}:${auth.password}`).toString('base64')}`
67
+ : undefined;
68
+ const get = (authorization) => request(url, {
69
+ headers: { Accept: accept, ...(authorization ? { Authorization: authorization } : {}) },
70
+ redirect: 'error',
71
+ signal: AbortSignal.timeout(30_000),
72
+ });
73
+ try {
74
+ let response = await get();
75
+ if (response.status === 401) {
76
+ const challenge = response.headers.get('www-authenticate') || '';
77
+ await response.body?.cancel();
78
+ if (/^Basic\s/i.test(challenge) && basic)
79
+ response = await get(basic);
80
+ else if (/^Bearer\s/i.test(challenge)) {
81
+ const params = Object.fromEntries([...challenge.matchAll(/(\w+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
82
+ const tokenUrl = new URL(params.realm);
83
+ if (tokenUrl.protocol !== 'https:' || tokenUrl.username || tokenUrl.password)
84
+ throw new Error();
85
+ if (params.service)
86
+ tokenUrl.searchParams.set('service', params.service);
87
+ tokenUrl.searchParams.set('scope', `repository:${repository}:pull`);
88
+ const tokenResponse = await request(tokenUrl, {
89
+ headers: basic ? { Authorization: basic } : {},
90
+ redirect: 'error',
91
+ signal: AbortSignal.timeout(30_000),
92
+ });
93
+ if (!tokenResponse.ok) {
94
+ await tokenResponse.body?.cancel();
95
+ throw new Error();
96
+ }
97
+ const body = (await tokenResponse.json());
98
+ const token = body.token || body.access_token;
99
+ if (!token || typeof token !== 'string')
100
+ throw new Error();
101
+ response = await get(`Bearer ${token}`);
102
+ }
103
+ else
104
+ throw new Error();
105
+ }
106
+ if (!response.ok) {
107
+ await response.body?.cancel();
108
+ throw new Error();
109
+ }
110
+ // Hash the exact bytes instead of trusting a registry's digest header. Bound manifest memory use.
111
+ const chunks = [];
112
+ let size = 0;
113
+ for await (const chunk of response.body) {
114
+ size += chunk.length;
115
+ if (size > 8 * 1024 * 1024)
116
+ throw new Error();
117
+ chunks.push(chunk);
118
+ }
119
+ const bytes = Buffer.concat(chunks);
120
+ const manifest = JSON.parse(bytes.toString());
121
+ if (manifest.schemaVersion !== 2 ||
122
+ !(Array.isArray(manifest.manifests) || (manifest.config && Array.isArray(manifest.layers))))
123
+ throw new Error();
124
+ const digest = `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
125
+ const reported = response.headers.get('docker-content-digest');
126
+ if (reported && reported !== digest)
127
+ throw new Error();
128
+ return `${name}@${digest}`;
129
+ }
130
+ catch {
131
+ throw new Error(`cannot resolve image ${image}; check registry availability and project ${REGISTRY_AUTH_SECRET} credentials`);
132
+ }
133
+ }
@@ -8,12 +8,8 @@ services:
8
8
  TUG_DATA_DIR: /data
9
9
  TUG_OPERATOR_TOKEN: {{OPERATOR_TOKEN}}
10
10
  TUG_MESH: {{MESH}}
11
- TUG_REGISTRY: {{REGISTRY_HOST}}
12
- TUG_REGISTRY_AUTH_PATH: {{REGISTRY_AUTH_PATH}}
13
- TUG_REGISTRY_CONTAINER: {{REGISTRY_CONTAINER}}
14
11
  ports:
15
12
  - "{{PORT}}:8080"
16
13
  volumes:
17
14
  - /var/lib/tug-control:/data
18
- - /var/lib/tug-registry-auth:/registry-auth
19
15
  - /var/run/docker.sock:/var/run/docker.sock
@@ -3,6 +3,5 @@ mkdir -p /etc/tug /var/lib/tug
3
3
  cat > /etc/tug/agent.env <<'ENV'
4
4
  {{ENV}}ENV
5
5
  chmod 0600 /etc/tug/agent.env
6
- {{DOCKER_LOGIN}}
7
6
  docker rm -f tug-agent >/dev/null 2>&1 || true
8
7
  docker run -d --name tug-agent --restart unless-stopped --env-file /etc/tug/agent.env {{RUN_FLAGS}}-v /var/run/docker.sock:/var/run/docker.sock -v /var/lib/tug:/var/lib/tug {{IMAGE_SH}} agent
package/dist/tokens.d.ts CHANGED
@@ -14,7 +14,7 @@ export declare const PROJECT_NAME_PATTERN: RegExp;
14
14
  /**
15
15
  * The single source of truth for who may call what. Every `/api/v1` route must appear here: createApp refuses to
16
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.
17
+ * credential check and are not behind the token guard. docs/access-control.md mirrors this table.
18
18
  */
19
19
  export declare const ROUTE_ROLES: Record<string, RouteRole>;
20
20
  /** The authenticated caller of an API request. `projects` is null when the credential is not project-scoped. */
@@ -60,14 +60,14 @@ export declare function parseTokenRequest(body: unknown): {
60
60
  expiresAt: string | null;
61
61
  };
62
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 & {
63
+ export declare function createToken(db: Db, request: ReturnType<typeof parseTokenRequest>, createdBy: string): TokenInfo & {
64
64
  token: string;
65
- }>;
66
- export declare function listTokens(db: Db): Promise<TokenInfo[]>;
65
+ };
66
+ export declare function listTokens(db: Db): TokenInfo[];
67
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>;
68
+ export declare function revokeToken(db: Db, name: string): boolean;
69
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>;
70
+ export declare function authenticate(db: Db, raw: string): Actor | null;
71
71
  /**
72
72
  * Which project a request targets: a name, `null` for control-wide data (admin only), or `undefined` when it cannot be
73
73
  * resolved (for example an unknown ID). Unresolvable targets are denied to scoped tokens without saying why, so they
@@ -88,4 +88,4 @@ export declare function recordEvent(db: Db, event: {
88
88
  project?: string | null;
89
89
  deploymentId?: string | null;
90
90
  metadata?: Record<string, unknown>;
91
- }): Promise<void>;
91
+ }): void;
package/dist/tokens.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { isUniqueViolation } from "./db-driver.js";
1
2
  import { hashToken, id, now, token } from "./util.js";
2
3
  /** Roles are cumulative: `deploy` includes `read`, and `admin` includes both. */
3
4
  export const ROLES = ['read', 'deploy', 'admin'];
@@ -14,7 +15,7 @@ const LAST_USED_INTERVAL_MS = 60_000;
14
15
  /**
15
16
  * The single source of truth for who may call what. Every `/api/v1` route must appear here: createApp refuses to
16
17
  * 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
+ * credential check and are not behind the token guard. docs/access-control.md mirrors this table.
18
19
  */
19
20
  export const ROUTE_ROLES = {
20
21
  'GET /api/v1/live': 'deploy',
@@ -38,8 +39,6 @@ export const ROUTE_ROLES = {
38
39
  'GET /api/v1/nodes': 'admin',
39
40
  'POST /api/v1/nodes/register': 'admin',
40
41
  'POST /api/v1/nodes/enroll-token': 'admin',
41
- 'POST /api/v1/nodes/:name/rotate-registry-token': 'admin',
42
- 'POST /api/v1/nodes/:name/revoke-registry-token': 'admin',
43
42
  'POST /api/v1/nodes/:name/rotate-wg-key': 'admin',
44
43
  'POST /api/v1/nodes/:name/ingress': 'admin',
45
44
  'DELETE /api/v1/nodes/:name': 'admin',
@@ -50,7 +49,6 @@ export const ROUTE_ROLES = {
50
49
  'POST /api/v1/tokens': 'admin',
51
50
  'GET /api/v1/tokens': 'admin',
52
51
  'DELETE /api/v1/tokens/:name': 'admin',
53
- 'POST /api/v1/registry/auth': 'public',
54
52
  };
55
53
  const statusError = (status, message) => Object.assign(new Error(message), { status });
56
54
  export const isRole = (value) => typeof value === 'string' && ROLES.includes(value);
@@ -98,46 +96,51 @@ const info = (row) => ({
98
96
  revokedAt: row.revoked_at,
99
97
  });
100
98
  /** Creates a token. The plaintext is returned once; only its hash is stored. */
101
- export async function createToken(db, request, createdBy) {
99
+ export function createToken(db, request, createdBy) {
102
100
  const value = token(TOKEN_PREFIX);
103
101
  try {
104
- 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);
102
+ 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);
105
103
  }
106
104
  catch (e) {
107
- if (/UNIQUE constraint/.test(e.message))
105
+ if (isUniqueViolation(e))
108
106
  throw statusError(409, `token name ${request.name} is already used (revoked tokens keep their names for the audit trail)`);
109
107
  throw e;
110
108
  }
111
109
  return {
112
- ...info((await db.get('SELECT * FROM api_tokens WHERE name=?', request.name))),
110
+ ...info(db.get('SELECT * FROM api_tokens WHERE name=?', request.name)),
113
111
  token: value,
114
112
  };
115
113
  }
116
- export async function listTokens(db) {
117
- return (await db.all('SELECT * FROM api_tokens ORDER BY created_at, name')).map(info);
114
+ export function listTokens(db) {
115
+ return db.all('SELECT * FROM api_tokens ORDER BY created_at, name').map(info);
118
116
  }
119
117
  /** Revokes a token immediately. Returns false if no such token exists; revoking twice keeps the first revocation time. */
120
- export async function revokeToken(db, name) {
121
- const row = await db.get('SELECT * FROM api_tokens WHERE name=?', name);
118
+ export function revokeToken(db, name) {
119
+ const row = db.get('SELECT * FROM api_tokens WHERE name=?', name);
122
120
  if (!row)
123
121
  return false;
124
- await db.run('UPDATE api_tokens SET revoked_at=? WHERE id=? AND revoked_at IS NULL', now(), row.id);
122
+ db.run('UPDATE api_tokens SET revoked_at=? WHERE id=? AND revoked_at IS NULL', now(), row.id);
125
123
  return true;
126
124
  }
127
125
  /** Resolves a bearer value to an actor by hash lookup: the root operator token first, then named tokens. */
128
- export async function authenticate(db, raw) {
126
+ export function authenticate(db, raw) {
129
127
  if (!raw)
130
128
  return null;
131
129
  const hash = hashToken(raw);
132
- const operator = await db.get('SELECT value FROM meta WHERE key=?', 'operator_token_hash');
130
+ const operator = db.get('SELECT value FROM meta WHERE key=?', 'operator_token_hash');
133
131
  if (operator && hash === operator.value)
134
132
  return { name: OPERATOR_ACTOR, role: 'admin', projects: null };
135
- const row = await db.get('SELECT * FROM api_tokens WHERE token_hash=?', hash);
133
+ const row = db.get('SELECT * FROM api_tokens WHERE token_hash=?', hash);
136
134
  if (!row || row.revoked_at || (row.expires_at && row.expires_at <= now()))
137
135
  return null;
138
- // Best effort and throttled: a slow or failing bookkeeping write must never fail or delay the request.
136
+ // Best effort and throttled: bookkeeping failures must never fail authentication.
139
137
  if (!row.last_used_at || Date.parse(row.last_used_at) < Date.now() - LAST_USED_INTERVAL_MS)
140
- void db.run('UPDATE api_tokens SET last_used_at=? WHERE id=?', now(), row.id).catch(() => { });
138
+ try {
139
+ db.run('UPDATE api_tokens SET last_used_at=? WHERE id=?', now(), row.id);
140
+ }
141
+ catch {
142
+ /* Best-effort bookkeeping. */
143
+ }
141
144
  return {
142
145
  name: row.name,
143
146
  role: row.role,
@@ -156,7 +159,7 @@ export function assertProject(actor, project) {
156
159
  export function createGuard(db) {
157
160
  return (required, resolve) => async (req, res, next) => {
158
161
  try {
159
- const actor = await authenticate(db, (req.headers.authorization || '').replace(/^Bearer /, ''));
162
+ const actor = authenticate(db, (req.headers.authorization || '').replace(/^Bearer /, ''));
160
163
  if (!actor) {
161
164
  res.status(401).json({ error: 'unauthorized' });
162
165
  return;
@@ -186,6 +189,6 @@ export function createGuard(db) {
186
189
  };
187
190
  }
188
191
  /** Appends a control-originated audit event naming the acting token. */
189
- export async function recordEvent(db, event) {
190
- 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);
192
+ export function recordEvent(db, event) {
193
+ 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);
191
194
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wpelevator/tug",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Portable VPS deployments with Docker Compose",
5
5
  "repository": {
6
6
  "type": "git",
@@ -43,8 +43,6 @@
43
43
  "express": "^4.21.2",
44
44
  "js-yaml": "^4.1.0",
45
45
  "multer": "^2.2.0",
46
- "sqlite": "^5.1.1",
47
- "sqlite3": "^5.1.7",
48
46
  "tar": "^7.4.3",
49
47
  "ws": "^8.22.1"
50
48
  },