@lifeaitools/clauth 1.30.25 → 1.31.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/.clauth-skill/SKILL.md +0 -31
- package/.clauth-skill/references/keys-guide.md +270 -270
- package/.clauth-skill/references/operator-guide.md +0 -27
- package/README.md +2 -48
- package/cli/api.js +238 -238
- package/cli/commands/agent-pool.js +51 -15
- package/cli/commands/install.js +396 -396
- package/cli/commands/login.js +135 -0
- package/cli/commands/login.test.js +73 -0
- package/cli/commands/serve.js +9 -815
- package/cli/commands/uninstall.js +164 -164
- package/cli/commands/watchdog.js +1 -1
- package/cli/index.js +29 -20
- package/cli/supervisor-registry.js +1 -6
- package/cli/watchdog-registry.js +2 -30
- package/cli/watchdog-registry.test.js +5 -28
- package/cli/webdav-service.js +339 -339
- package/install.ps1 +102 -102
- package/install.sh +49 -49
- package/package.json +4 -6
- package/scripts/bin/bootstrap-linux +0 -0
- package/scripts/bin/bootstrap-macos +0 -0
- package/scripts/bin/bootstrap-win.exe +0 -0
- package/scripts/bootstrap.cjs +121 -121
- package/scripts/build.mjs +66 -0
- package/scripts/build.sh +5 -45
- package/scripts/postinstall.js +189 -189
- package/supabase/functions/auth-vault/index.ts +350 -350
- package/supabase/migrations/001_clauth_schema.sql +94 -94
- package/supabase/migrations/002_vault_helpers.sql +90 -90
- package/supabase/migrations/20260317_lockout.sql +26 -26
- package/cli/commands/ops-install.js +0 -211
- package/cli/commands/ops.js +0 -69
- package/cli/ops/coolify-adapter.js +0 -80
- package/cli/ops/deployment-adapter.js +0 -63
- package/cli/ops/job-store.js +0 -116
- package/cli/ops/operation-policy.js +0 -51
- package/cli/ops/pm2-adapter.js +0 -128
- package/cli/ops/serialized-executor.js +0 -9
|
@@ -1,32 +1,32 @@
|
|
|
1
|
-
-- ============================================================
|
|
2
|
-
-- clauth schema
|
|
3
|
-
-- Migration: 001_clauth_schema.sql
|
|
4
|
-
-- ============================================================
|
|
5
|
-
|
|
6
|
-
-- Enable vault extension (Supabase enables by default, but guard)
|
|
7
|
-
-- vault.secrets is already available in Supabase projects
|
|
8
|
-
|
|
9
|
-
-- ============================================================
|
|
10
|
-
-- Service Registry
|
|
11
|
-
-- ============================================================
|
|
12
|
-
create table if not exists public.clauth_services (
|
|
13
|
-
id uuid primary key default gen_random_uuid(),
|
|
14
|
-
name text not null unique, -- e.g. 'github', 'r2'
|
|
15
|
-
label text not null, -- human display name
|
|
16
|
-
key_type text not null -- 'token' | 'keypair' | 'connstring' | 'oauth'
|
|
17
|
-
check (key_type in ('token','keypair','connstring','oauth')),
|
|
18
|
-
enabled boolean not null default false,
|
|
19
|
-
vault_key text, -- vault secret name: 'clauth.<name>'
|
|
20
|
-
description text,
|
|
21
|
-
last_retrieved timestamptz,
|
|
22
|
-
last_rotated timestamptz,
|
|
23
|
-
created_at timestamptz not null default now(),
|
|
24
|
-
updated_at timestamptz not null default now()
|
|
25
|
-
);
|
|
26
|
-
|
|
27
|
-
-- ============================================================
|
|
28
|
-
-- Machine Registry (hardware fingerprints — hashed only)
|
|
29
|
-
-- ============================================================
|
|
1
|
+
-- ============================================================
|
|
2
|
+
-- clauth schema
|
|
3
|
+
-- Migration: 001_clauth_schema.sql
|
|
4
|
+
-- ============================================================
|
|
5
|
+
|
|
6
|
+
-- Enable vault extension (Supabase enables by default, but guard)
|
|
7
|
+
-- vault.secrets is already available in Supabase projects
|
|
8
|
+
|
|
9
|
+
-- ============================================================
|
|
10
|
+
-- Service Registry
|
|
11
|
+
-- ============================================================
|
|
12
|
+
create table if not exists public.clauth_services (
|
|
13
|
+
id uuid primary key default gen_random_uuid(),
|
|
14
|
+
name text not null unique, -- e.g. 'github', 'r2'
|
|
15
|
+
label text not null, -- human display name
|
|
16
|
+
key_type text not null -- 'token' | 'keypair' | 'connstring' | 'oauth'
|
|
17
|
+
check (key_type in ('token','keypair','connstring','oauth')),
|
|
18
|
+
enabled boolean not null default false,
|
|
19
|
+
vault_key text, -- vault secret name: 'clauth.<name>'
|
|
20
|
+
description text,
|
|
21
|
+
last_retrieved timestamptz,
|
|
22
|
+
last_rotated timestamptz,
|
|
23
|
+
created_at timestamptz not null default now(),
|
|
24
|
+
updated_at timestamptz not null default now()
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
-- ============================================================
|
|
28
|
+
-- Machine Registry (hardware fingerprints — hashed only)
|
|
29
|
+
-- ============================================================
|
|
30
30
|
create table if not exists public.clauth_machines (
|
|
31
31
|
id uuid primary key default gen_random_uuid(),
|
|
32
32
|
install_id text not null default 'default', -- logical vault install / owner group
|
|
@@ -39,68 +39,68 @@ create table if not exists public.clauth_machines (
|
|
|
39
39
|
created_at timestamptz not null default now(),
|
|
40
40
|
last_seen timestamptz
|
|
41
41
|
);
|
|
42
|
-
|
|
43
|
-
-- ============================================================
|
|
44
|
-
-- Audit Log
|
|
45
|
-
-- ============================================================
|
|
46
|
-
create table if not exists public.clauth_audit (
|
|
47
|
-
id uuid primary key default gen_random_uuid(),
|
|
48
|
-
machine_hash text,
|
|
49
|
-
service_name text,
|
|
50
|
-
action text not null, -- 'retrieve' | 'enable' | 'disable' | 'rotate' | 'revoke' | 'add' | 'remove'
|
|
51
|
-
result text not null, -- 'success' | 'fail' | 'denied'
|
|
52
|
-
detail text,
|
|
53
|
-
created_at timestamptz not null default now()
|
|
54
|
-
);
|
|
55
|
-
|
|
56
|
-
-- ============================================================
|
|
57
|
-
-- Seed: built-in service definitions (no keys — just registry)
|
|
58
|
-
-- ============================================================
|
|
59
|
-
insert into public.clauth_services (name, label, key_type, description) values
|
|
60
|
-
('github', 'GitHub PAT', 'token', 'GitHub Personal Access Token — repo, workflow, org'),
|
|
61
|
-
('supabase-anon', 'Supabase Anon Key', 'token', 'Supabase public anon key (RLS-gated)'),
|
|
62
|
-
('supabase-service', 'Supabase Service Role Key', 'token', 'Supabase service_role — bypasses RLS'),
|
|
63
|
-
('supabase-db', 'Supabase DB Connection', 'connstring','PostgreSQL connection string (pooled + direct)'),
|
|
64
|
-
('vercel', 'Vercel API Token', 'keypair', 'Vercel API token + Team ID'),
|
|
65
|
-
('namecheap', 'Namecheap API', 'keypair', 'Namecheap API key + username'),
|
|
66
|
-
('neo4j', 'Neo4j Aura', 'connstring','Neo4j Aura URI + credentials'),
|
|
67
|
-
('anthropic', 'Anthropic API Key', 'token', 'Claude API — sk-ant-...'),
|
|
68
|
-
('r2', 'Cloudflare R2 Keypair', 'keypair', 'R2 Access Key ID + Secret Access Key'),
|
|
69
|
-
('r2-bucket', 'Cloudflare R2 Bucket Config', 'connstring','R2 bucket name + endpoint URL'),
|
|
70
|
-
('cloudflare', 'Cloudflare API Token', 'token', 'Cloudflare zone/DNS/admin token'),
|
|
71
|
-
('rocketreach', 'RocketReach API Key', 'token', 'RocketReach contact intelligence API')
|
|
72
|
-
on conflict (name) do nothing;
|
|
73
|
-
|
|
74
|
-
-- ============================================================
|
|
75
|
-
-- RLS — lock down all tables
|
|
76
|
-
-- ============================================================
|
|
77
|
-
alter table public.clauth_services enable row level security;
|
|
78
|
-
alter table public.clauth_machines enable row level security;
|
|
79
|
-
alter table public.clauth_audit enable row level security;
|
|
80
|
-
|
|
81
|
-
-- service_role bypasses RLS — all access from Edge Function only
|
|
82
|
-
-- No direct anon access to any clauth table
|
|
83
|
-
do $$ begin
|
|
84
|
-
if not exists (select 1 from pg_policies where policyname = 'no_anon_services' and tablename = 'clauth_services') then
|
|
85
|
-
create policy "no_anon_services" on public.clauth_services for all using (false);
|
|
86
|
-
end if;
|
|
87
|
-
if not exists (select 1 from pg_policies where policyname = 'no_anon_machines' and tablename = 'clauth_machines') then
|
|
88
|
-
create policy "no_anon_machines" on public.clauth_machines for all using (false);
|
|
89
|
-
end if;
|
|
90
|
-
if not exists (select 1 from pg_policies where policyname = 'no_anon_audit' and tablename = 'clauth_audit') then
|
|
91
|
-
create policy "no_anon_audit" on public.clauth_audit for all using (false);
|
|
92
|
-
end if;
|
|
93
|
-
end $$;
|
|
94
|
-
|
|
95
|
-
-- ============================================================
|
|
96
|
-
-- Updated_at trigger
|
|
97
|
-
-- ============================================================
|
|
98
|
-
create or replace function public.clauth_touch_updated()
|
|
99
|
-
returns trigger language plpgsql as $$
|
|
100
|
-
begin new.updated_at = now(); return new; end;
|
|
101
|
-
$$;
|
|
102
|
-
|
|
103
|
-
drop trigger if exists clauth_services_updated on public.clauth_services;
|
|
104
|
-
create trigger clauth_services_updated
|
|
105
|
-
before update on public.clauth_services
|
|
106
|
-
for each row execute procedure public.clauth_touch_updated();
|
|
42
|
+
|
|
43
|
+
-- ============================================================
|
|
44
|
+
-- Audit Log
|
|
45
|
+
-- ============================================================
|
|
46
|
+
create table if not exists public.clauth_audit (
|
|
47
|
+
id uuid primary key default gen_random_uuid(),
|
|
48
|
+
machine_hash text,
|
|
49
|
+
service_name text,
|
|
50
|
+
action text not null, -- 'retrieve' | 'enable' | 'disable' | 'rotate' | 'revoke' | 'add' | 'remove'
|
|
51
|
+
result text not null, -- 'success' | 'fail' | 'denied'
|
|
52
|
+
detail text,
|
|
53
|
+
created_at timestamptz not null default now()
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
-- ============================================================
|
|
57
|
+
-- Seed: built-in service definitions (no keys — just registry)
|
|
58
|
+
-- ============================================================
|
|
59
|
+
insert into public.clauth_services (name, label, key_type, description) values
|
|
60
|
+
('github', 'GitHub PAT', 'token', 'GitHub Personal Access Token — repo, workflow, org'),
|
|
61
|
+
('supabase-anon', 'Supabase Anon Key', 'token', 'Supabase public anon key (RLS-gated)'),
|
|
62
|
+
('supabase-service', 'Supabase Service Role Key', 'token', 'Supabase service_role — bypasses RLS'),
|
|
63
|
+
('supabase-db', 'Supabase DB Connection', 'connstring','PostgreSQL connection string (pooled + direct)'),
|
|
64
|
+
('vercel', 'Vercel API Token', 'keypair', 'Vercel API token + Team ID'),
|
|
65
|
+
('namecheap', 'Namecheap API', 'keypair', 'Namecheap API key + username'),
|
|
66
|
+
('neo4j', 'Neo4j Aura', 'connstring','Neo4j Aura URI + credentials'),
|
|
67
|
+
('anthropic', 'Anthropic API Key', 'token', 'Claude API — sk-ant-...'),
|
|
68
|
+
('r2', 'Cloudflare R2 Keypair', 'keypair', 'R2 Access Key ID + Secret Access Key'),
|
|
69
|
+
('r2-bucket', 'Cloudflare R2 Bucket Config', 'connstring','R2 bucket name + endpoint URL'),
|
|
70
|
+
('cloudflare', 'Cloudflare API Token', 'token', 'Cloudflare zone/DNS/admin token'),
|
|
71
|
+
('rocketreach', 'RocketReach API Key', 'token', 'RocketReach contact intelligence API')
|
|
72
|
+
on conflict (name) do nothing;
|
|
73
|
+
|
|
74
|
+
-- ============================================================
|
|
75
|
+
-- RLS — lock down all tables
|
|
76
|
+
-- ============================================================
|
|
77
|
+
alter table public.clauth_services enable row level security;
|
|
78
|
+
alter table public.clauth_machines enable row level security;
|
|
79
|
+
alter table public.clauth_audit enable row level security;
|
|
80
|
+
|
|
81
|
+
-- service_role bypasses RLS — all access from Edge Function only
|
|
82
|
+
-- No direct anon access to any clauth table
|
|
83
|
+
do $$ begin
|
|
84
|
+
if not exists (select 1 from pg_policies where policyname = 'no_anon_services' and tablename = 'clauth_services') then
|
|
85
|
+
create policy "no_anon_services" on public.clauth_services for all using (false);
|
|
86
|
+
end if;
|
|
87
|
+
if not exists (select 1 from pg_policies where policyname = 'no_anon_machines' and tablename = 'clauth_machines') then
|
|
88
|
+
create policy "no_anon_machines" on public.clauth_machines for all using (false);
|
|
89
|
+
end if;
|
|
90
|
+
if not exists (select 1 from pg_policies where policyname = 'no_anon_audit' and tablename = 'clauth_audit') then
|
|
91
|
+
create policy "no_anon_audit" on public.clauth_audit for all using (false);
|
|
92
|
+
end if;
|
|
93
|
+
end $$;
|
|
94
|
+
|
|
95
|
+
-- ============================================================
|
|
96
|
+
-- Updated_at trigger
|
|
97
|
+
-- ============================================================
|
|
98
|
+
create or replace function public.clauth_touch_updated()
|
|
99
|
+
returns trigger language plpgsql as $$
|
|
100
|
+
begin new.updated_at = now(); return new; end;
|
|
101
|
+
$$;
|
|
102
|
+
|
|
103
|
+
drop trigger if exists clauth_services_updated on public.clauth_services;
|
|
104
|
+
create trigger clauth_services_updated
|
|
105
|
+
before update on public.clauth_services
|
|
106
|
+
for each row execute procedure public.clauth_touch_updated();
|
|
@@ -1,90 +1,90 @@
|
|
|
1
|
-
-- ============================================================
|
|
2
|
-
-- Vault helper functions
|
|
3
|
-
-- Migration: 002_vault_helpers.sql
|
|
4
|
-
-- These wrap vault.create_secret / vault.update_secret
|
|
5
|
-
-- so Edge Functions don't need direct vault schema access
|
|
6
|
-
-- ============================================================
|
|
7
|
-
|
|
8
|
-
-- Upsert a secret (create or update)
|
|
9
|
-
create or replace function public.vault_upsert_secret(
|
|
10
|
-
secret_name text,
|
|
11
|
-
secret_value text
|
|
12
|
-
)
|
|
13
|
-
returns void
|
|
14
|
-
language plpgsql
|
|
15
|
-
security definer
|
|
16
|
-
as $$
|
|
17
|
-
declare
|
|
18
|
-
existing_id uuid;
|
|
19
|
-
begin
|
|
20
|
-
select id into existing_id
|
|
21
|
-
from vault.secrets
|
|
22
|
-
where name = secret_name
|
|
23
|
-
limit 1;
|
|
24
|
-
|
|
25
|
-
if existing_id is not null then
|
|
26
|
-
perform vault.update_secret(existing_id, secret_value);
|
|
27
|
-
else
|
|
28
|
-
perform vault.create_secret(secret_value, secret_name);
|
|
29
|
-
end if;
|
|
30
|
-
end;
|
|
31
|
-
$$;
|
|
32
|
-
|
|
33
|
-
-- Decrypt and return a secret value by name
|
|
34
|
-
create or replace function public.vault_decrypt_secret(
|
|
35
|
-
secret_name text
|
|
36
|
-
)
|
|
37
|
-
returns text
|
|
38
|
-
language plpgsql
|
|
39
|
-
security definer
|
|
40
|
-
as $$
|
|
41
|
-
declare
|
|
42
|
-
result text;
|
|
43
|
-
begin
|
|
44
|
-
select decrypted_secret into result
|
|
45
|
-
from vault.decrypted_secrets
|
|
46
|
-
where name = secret_name
|
|
47
|
-
limit 1;
|
|
48
|
-
return result;
|
|
49
|
-
end;
|
|
50
|
-
$$;
|
|
51
|
-
|
|
52
|
-
-- Delete a secret by name
|
|
53
|
-
create or replace function public.vault_delete_secret(
|
|
54
|
-
secret_name text
|
|
55
|
-
)
|
|
56
|
-
returns void
|
|
57
|
-
language plpgsql
|
|
58
|
-
security definer
|
|
59
|
-
as $$
|
|
60
|
-
declare
|
|
61
|
-
target_id uuid;
|
|
62
|
-
begin
|
|
63
|
-
select id into target_id
|
|
64
|
-
from vault.secrets
|
|
65
|
-
where name = secret_name
|
|
66
|
-
limit 1;
|
|
67
|
-
|
|
68
|
-
if target_id is not null then
|
|
69
|
-
delete from vault.secrets where id = target_id;
|
|
70
|
-
end if;
|
|
71
|
-
end;
|
|
72
|
-
$$;
|
|
73
|
-
|
|
74
|
-
-- List all clauth secrets (names only — never values)
|
|
75
|
-
create or replace function public.vault_list_clauth_secrets()
|
|
76
|
-
returns table(name text, created_at timestamptz, updated_at timestamptz)
|
|
77
|
-
language sql
|
|
78
|
-
security definer
|
|
79
|
-
as $$
|
|
80
|
-
select name, created_at, updated_at
|
|
81
|
-
from vault.secrets
|
|
82
|
-
where name like 'clauth.%'
|
|
83
|
-
order by name;
|
|
84
|
-
$$;
|
|
85
|
-
|
|
86
|
-
-- Revoke execute from public, grant to service_role only
|
|
87
|
-
revoke execute on function public.vault_upsert_secret from public;
|
|
88
|
-
revoke execute on function public.vault_decrypt_secret from public;
|
|
89
|
-
revoke execute on function public.vault_delete_secret from public;
|
|
90
|
-
revoke execute on function public.vault_list_clauth_secrets from public;
|
|
1
|
+
-- ============================================================
|
|
2
|
+
-- Vault helper functions
|
|
3
|
+
-- Migration: 002_vault_helpers.sql
|
|
4
|
+
-- These wrap vault.create_secret / vault.update_secret
|
|
5
|
+
-- so Edge Functions don't need direct vault schema access
|
|
6
|
+
-- ============================================================
|
|
7
|
+
|
|
8
|
+
-- Upsert a secret (create or update)
|
|
9
|
+
create or replace function public.vault_upsert_secret(
|
|
10
|
+
secret_name text,
|
|
11
|
+
secret_value text
|
|
12
|
+
)
|
|
13
|
+
returns void
|
|
14
|
+
language plpgsql
|
|
15
|
+
security definer
|
|
16
|
+
as $$
|
|
17
|
+
declare
|
|
18
|
+
existing_id uuid;
|
|
19
|
+
begin
|
|
20
|
+
select id into existing_id
|
|
21
|
+
from vault.secrets
|
|
22
|
+
where name = secret_name
|
|
23
|
+
limit 1;
|
|
24
|
+
|
|
25
|
+
if existing_id is not null then
|
|
26
|
+
perform vault.update_secret(existing_id, secret_value);
|
|
27
|
+
else
|
|
28
|
+
perform vault.create_secret(secret_value, secret_name);
|
|
29
|
+
end if;
|
|
30
|
+
end;
|
|
31
|
+
$$;
|
|
32
|
+
|
|
33
|
+
-- Decrypt and return a secret value by name
|
|
34
|
+
create or replace function public.vault_decrypt_secret(
|
|
35
|
+
secret_name text
|
|
36
|
+
)
|
|
37
|
+
returns text
|
|
38
|
+
language plpgsql
|
|
39
|
+
security definer
|
|
40
|
+
as $$
|
|
41
|
+
declare
|
|
42
|
+
result text;
|
|
43
|
+
begin
|
|
44
|
+
select decrypted_secret into result
|
|
45
|
+
from vault.decrypted_secrets
|
|
46
|
+
where name = secret_name
|
|
47
|
+
limit 1;
|
|
48
|
+
return result;
|
|
49
|
+
end;
|
|
50
|
+
$$;
|
|
51
|
+
|
|
52
|
+
-- Delete a secret by name
|
|
53
|
+
create or replace function public.vault_delete_secret(
|
|
54
|
+
secret_name text
|
|
55
|
+
)
|
|
56
|
+
returns void
|
|
57
|
+
language plpgsql
|
|
58
|
+
security definer
|
|
59
|
+
as $$
|
|
60
|
+
declare
|
|
61
|
+
target_id uuid;
|
|
62
|
+
begin
|
|
63
|
+
select id into target_id
|
|
64
|
+
from vault.secrets
|
|
65
|
+
where name = secret_name
|
|
66
|
+
limit 1;
|
|
67
|
+
|
|
68
|
+
if target_id is not null then
|
|
69
|
+
delete from vault.secrets where id = target_id;
|
|
70
|
+
end if;
|
|
71
|
+
end;
|
|
72
|
+
$$;
|
|
73
|
+
|
|
74
|
+
-- List all clauth secrets (names only — never values)
|
|
75
|
+
create or replace function public.vault_list_clauth_secrets()
|
|
76
|
+
returns table(name text, created_at timestamptz, updated_at timestamptz)
|
|
77
|
+
language sql
|
|
78
|
+
security definer
|
|
79
|
+
as $$
|
|
80
|
+
select name, created_at, updated_at
|
|
81
|
+
from vault.secrets
|
|
82
|
+
where name like 'clauth.%'
|
|
83
|
+
order by name;
|
|
84
|
+
$$;
|
|
85
|
+
|
|
86
|
+
-- Revoke execute from public, grant to service_role only
|
|
87
|
+
revoke execute on function public.vault_upsert_secret from public;
|
|
88
|
+
revoke execute on function public.vault_decrypt_secret from public;
|
|
89
|
+
revoke execute on function public.vault_delete_secret from public;
|
|
90
|
+
revoke execute on function public.vault_list_clauth_secrets from public;
|
|
@@ -1,26 +1,26 @@
|
|
|
1
|
-
-- Migration: add fail_count and locked to clauth_machines
|
|
2
|
-
-- Run via: clauth install (picks this up automatically) or apply manually
|
|
3
|
-
|
|
4
|
-
ALTER TABLE clauth_machines
|
|
5
|
-
ADD COLUMN IF NOT EXISTS fail_count INTEGER NOT NULL DEFAULT 0,
|
|
6
|
-
ADD COLUMN IF NOT EXISTS locked BOOLEAN NOT NULL DEFAULT false;
|
|
7
|
-
|
|
8
|
-
-- Index for fast lockout checks
|
|
9
|
-
CREATE INDEX IF NOT EXISTS idx_clauth_machines_locked
|
|
10
|
-
ON clauth_machines (machine_hash, locked);
|
|
11
|
-
|
|
12
|
-
-- Admin helper: unlock a machine
|
|
13
|
-
-- Usage: SELECT clauth_unlock_machine('your_machine_hash');
|
|
14
|
-
CREATE OR REPLACE FUNCTION clauth_unlock_machine(p_machine_hash TEXT)
|
|
15
|
-
RETURNS void
|
|
16
|
-
LANGUAGE plpgsql
|
|
17
|
-
SECURITY DEFINER
|
|
18
|
-
AS $$
|
|
19
|
-
BEGIN
|
|
20
|
-
UPDATE clauth_machines
|
|
21
|
-
SET locked = false, fail_count = 0
|
|
22
|
-
WHERE machine_hash = p_machine_hash;
|
|
23
|
-
END;
|
|
24
|
-
$$;
|
|
25
|
-
|
|
26
|
-
REVOKE EXECUTE ON FUNCTION clauth_unlock_machine(TEXT) FROM PUBLIC;
|
|
1
|
+
-- Migration: add fail_count and locked to clauth_machines
|
|
2
|
+
-- Run via: clauth install (picks this up automatically) or apply manually
|
|
3
|
+
|
|
4
|
+
ALTER TABLE clauth_machines
|
|
5
|
+
ADD COLUMN IF NOT EXISTS fail_count INTEGER NOT NULL DEFAULT 0,
|
|
6
|
+
ADD COLUMN IF NOT EXISTS locked BOOLEAN NOT NULL DEFAULT false;
|
|
7
|
+
|
|
8
|
+
-- Index for fast lockout checks
|
|
9
|
+
CREATE INDEX IF NOT EXISTS idx_clauth_machines_locked
|
|
10
|
+
ON clauth_machines (machine_hash, locked);
|
|
11
|
+
|
|
12
|
+
-- Admin helper: unlock a machine
|
|
13
|
+
-- Usage: SELECT clauth_unlock_machine('your_machine_hash');
|
|
14
|
+
CREATE OR REPLACE FUNCTION clauth_unlock_machine(p_machine_hash TEXT)
|
|
15
|
+
RETURNS void
|
|
16
|
+
LANGUAGE plpgsql
|
|
17
|
+
SECURITY DEFINER
|
|
18
|
+
AS $$
|
|
19
|
+
BEGIN
|
|
20
|
+
UPDATE clauth_machines
|
|
21
|
+
SET locked = false, fail_count = 0
|
|
22
|
+
WHERE machine_hash = p_machine_hash;
|
|
23
|
+
END;
|
|
24
|
+
$$;
|
|
25
|
+
|
|
26
|
+
REVOKE EXECUTE ON FUNCTION clauth_unlock_machine(TEXT) FROM PUBLIC;
|
|
@@ -1,211 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
4
|
-
import pm2 from "pm2";
|
|
5
|
-
|
|
6
|
-
const DEFAULT_PORT = 52439;
|
|
7
|
-
const DEFAULT_NAME = "clauth-deployment";
|
|
8
|
-
const INSTALLER_MARKER = "clauth.ops.install.v1";
|
|
9
|
-
const OPS_ENV_KEYS = new Set([
|
|
10
|
-
"CLAUTH_OPS_INSTALLER_MARKER", "CLAUTH_OPS_ENABLED", "CLAUTH_OPS_APPLICATIONS", "CLAUTH_OPS_ADMIN_ENABLED",
|
|
11
|
-
"CLAUTH_OPS_ADMIN_APPLICATIONS", "CLAUTH_OPS_ALLOW_HOST_WIDE", "CLAUTH_OPS_DEPLOYMENTS", "CLAUTH_COOLIFY_PROMOTE_UUIDS",
|
|
12
|
-
]);
|
|
13
|
-
const RUNTIME_ENV_KEYS = new Set(["PATH", "Path", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "SystemRoot", "SYSTEMROOT", "ComSpec", "TMP", "TEMP"]);
|
|
14
|
-
const RESTORABLE_PM2_KEYS = ["node_args", "exec_mode", "instances", "namespace", "watch", "kill_retry_time", "merge_logs", "windowsHide", "stop_exit_codes"];
|
|
15
|
-
|
|
16
|
-
function requiredString(value, label) {
|
|
17
|
-
if (typeof value !== "string" || !value.trim()) throw new Error(`${label} is required`);
|
|
18
|
-
return value.trim();
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function stringList(value, label) {
|
|
22
|
-
if (value == null) return [];
|
|
23
|
-
if (!Array.isArray(value) || !value.every((item) => typeof item === "string" && item.trim())) throw new Error(`${label} must be an array of non-empty strings`);
|
|
24
|
-
return value.map((item) => item.trim());
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function applicationPolicy(value, label) {
|
|
28
|
-
if (value == null) return {};
|
|
29
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
|
|
30
|
-
return Object.fromEntries(Object.entries(value).map(([operation, targets]) => [operation, stringList(targets, `${label}.${operation}`)]));
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* An operation is only usable when it ALSO has a target allowlist.
|
|
35
|
-
*
|
|
36
|
-
* `createOperationPolicy` reads the allowlist from `applications[operation]` and
|
|
37
|
-
* rejects with `service_not_available` when it is empty (operation-policy.js,
|
|
38
|
-
* the `!allowed.length` guard). That code reads like the service is down, but
|
|
39
|
-
* it actually means "no targets configured" — so a config carrying only
|
|
40
|
-
* `agent_enabled` installs cleanly, passes the health and bearer-gate probes,
|
|
41
|
-
* and then rejects every authenticated call. That exact combination shipped to
|
|
42
|
-
* the canary and was misdiagnosed for a day as a missing credential.
|
|
43
|
-
*
|
|
44
|
-
* Read operations (`list`, `describe`, `ping`, `logs`, …) are affected too: the
|
|
45
|
-
* `!allowed.length` guard runs before the read/mutate distinction, so a read op
|
|
46
|
-
* with no allowlist is rejected just the same.
|
|
47
|
-
*
|
|
48
|
-
* Fail at install instead, naming the operations that would silently break.
|
|
49
|
-
*/
|
|
50
|
-
function assertEnabledOperationsHaveTargets(enabled, applications, label) {
|
|
51
|
-
const missing = enabled.filter((operation) => !(applications[operation] || []).length);
|
|
52
|
-
if (!missing.length) return;
|
|
53
|
-
const applicationsLabel = label.replace("_enabled", "_applications");
|
|
54
|
-
throw new Error(
|
|
55
|
-
`${label}: ${missing.join(", ")} enabled but absent from ${applicationsLabel}. An enabled operation with no target allowlist is rejected at runtime with service_not_available. Add a target list (e.g. {"${missing[0]}": ["*"]}) or remove it from the enabled list.`,
|
|
56
|
-
);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function loadOpsInstallConfig(configPath) {
|
|
60
|
-
const raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
61
|
-
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("ops installer config must be a JSON object");
|
|
62
|
-
const port = raw.port == null ? DEFAULT_PORT : Number(raw.port);
|
|
63
|
-
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error("port must be an integer from 1024 through 65535");
|
|
64
|
-
const pm2Name = raw.pm2_name == null ? DEFAULT_NAME : requiredString(raw.pm2_name, "pm2_name");
|
|
65
|
-
const operations = raw.operations && typeof raw.operations === "object" && !Array.isArray(raw.operations) ? raw.operations : {};
|
|
66
|
-
|
|
67
|
-
const enabled = stringList(operations.agent_enabled, "operations.agent_enabled");
|
|
68
|
-
const applications = applicationPolicy(operations.agent_applications, "operations.agent_applications");
|
|
69
|
-
const adminEnabled = stringList(operations.admin_enabled, "operations.admin_enabled");
|
|
70
|
-
const adminApplications = applicationPolicy(operations.admin_applications, "operations.admin_applications");
|
|
71
|
-
|
|
72
|
-
assertEnabledOperationsHaveTargets(enabled, applications, "operations.agent_enabled");
|
|
73
|
-
assertEnabledOperationsHaveTargets(adminEnabled, adminApplications, "operations.admin_enabled");
|
|
74
|
-
|
|
75
|
-
return {
|
|
76
|
-
port,
|
|
77
|
-
pm2Name,
|
|
78
|
-
enabled,
|
|
79
|
-
applications,
|
|
80
|
-
adminEnabled,
|
|
81
|
-
adminApplications,
|
|
82
|
-
allowHostWide: operations.allow_host_wide === true,
|
|
83
|
-
deployments: raw.deployments && typeof raw.deployments === "object" && !Array.isArray(raw.deployments) ? raw.deployments : {},
|
|
84
|
-
coolifyPromoteUuids: stringList(raw.coolify_promote_uuids, "coolify_promote_uuids"),
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export function buildOpsServiceSpec({ config, rootDir }) {
|
|
89
|
-
const cli = path.join(rootDir, "cli", "index.js");
|
|
90
|
-
const runtimeEnv = Object.fromEntries([...RUNTIME_ENV_KEYS].flatMap((key) => process.env[key] == null ? [] : [[key, process.env[key]]]));
|
|
91
|
-
return {
|
|
92
|
-
name: config.pm2Name,
|
|
93
|
-
script: cli,
|
|
94
|
-
args: `serve foreground --port ${config.port} --isolated`,
|
|
95
|
-
cwd: rootDir,
|
|
96
|
-
autorestart: true,
|
|
97
|
-
restart_delay: 1000,
|
|
98
|
-
max_restarts: 20,
|
|
99
|
-
// Do not inherit the invoking agent or shell environment: PM2 otherwise
|
|
100
|
-
// stores every inherited value in its process metadata.
|
|
101
|
-
// PM2 only activates filtering for a non-empty array. Exclude every
|
|
102
|
-
// inherited key, then explicitly add the small runtime allowlist above.
|
|
103
|
-
filter_env: Object.keys(process.env),
|
|
104
|
-
env: {
|
|
105
|
-
...runtimeEnv,
|
|
106
|
-
CLAUTH_OPS_INSTALLER_MARKER: INSTALLER_MARKER,
|
|
107
|
-
CLAUTH_OPS_ENABLED: [...config.enabled, ...(Object.keys(config.deployments).length ? ["deploy"] : []), ...(config.coolifyPromoteUuids.length ? ["coolify_promote"] : [])].join(","),
|
|
108
|
-
CLAUTH_OPS_APPLICATIONS: JSON.stringify(config.applications),
|
|
109
|
-
CLAUTH_OPS_ADMIN_ENABLED: config.adminEnabled.join(","),
|
|
110
|
-
CLAUTH_OPS_ADMIN_APPLICATIONS: JSON.stringify(config.adminApplications),
|
|
111
|
-
CLAUTH_OPS_ALLOW_HOST_WIDE: config.allowHostWide ? "1" : "0",
|
|
112
|
-
CLAUTH_OPS_DEPLOYMENTS: JSON.stringify(config.deployments),
|
|
113
|
-
CLAUTH_COOLIFY_PROMOTE_UUIDS: JSON.stringify(config.coolifyPromoteUuids),
|
|
114
|
-
},
|
|
115
|
-
};
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function pm2Call(method, ...args) {
|
|
119
|
-
return new Promise((resolve, reject) => pm2[method](...args, (error, value) => error ? reject(error) : resolve(value)));
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
async function probe(endpoint) {
|
|
123
|
-
const health = await fetch(`${endpoint}/health`, { signal: AbortSignal.timeout(5000) });
|
|
124
|
-
const healthBody = health.ok ? await health.json() : null;
|
|
125
|
-
if (healthBody?.status !== "ok") throw new Error(`health probe failed at ${endpoint}`);
|
|
126
|
-
const catalog = await fetch(`${endpoint}/v1/ops/catalog`, { signal: AbortSignal.timeout(5000) });
|
|
127
|
-
if (catalog.status !== 401) throw new Error(`bearer gate probe failed: expected HTTP 401, received ${catalog.status}`);
|
|
128
|
-
return { health_status: health.status, bearer_gate_status: catalog.status, listening_port: healthBody.listening_port, process_id: healthBody.process_id };
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function argsFor(entry) {
|
|
132
|
-
const value = entry?.pm2_env?.args ?? entry?.args ?? "";
|
|
133
|
-
return Array.isArray(value) ? value.join(" ") : String(value || "");
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function ownedService(entry, spec) {
|
|
137
|
-
const env = entry?.pm2_env || {};
|
|
138
|
-
return env.CLAUTH_OPS_INSTALLER_MARKER === INSTALLER_MARKER
|
|
139
|
-
&& path.resolve(env.pm_exec_path || "") === path.resolve(spec.script)
|
|
140
|
-
&& path.resolve(env.pm_cwd || "") === path.resolve(spec.cwd)
|
|
141
|
-
&& argsFor(entry) === spec.args;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
export function previousSpec(entry) {
|
|
145
|
-
const env = entry.pm2_env || {};
|
|
146
|
-
const restored = {
|
|
147
|
-
name: env.name,
|
|
148
|
-
script: env.pm_exec_path,
|
|
149
|
-
args: argsFor(entry),
|
|
150
|
-
cwd: env.pm_cwd,
|
|
151
|
-
autorestart: env.autorestart !== false,
|
|
152
|
-
restart_delay: env.restart_delay || 1000,
|
|
153
|
-
max_restarts: env.max_restarts || 20,
|
|
154
|
-
filter_env: Object.keys(process.env),
|
|
155
|
-
env: Object.fromEntries(Object.entries(env).filter(([key]) => OPS_ENV_KEYS.has(key) || RUNTIME_ENV_KEYS.has(key))),
|
|
156
|
-
};
|
|
157
|
-
if (env.exec_interpreter !== undefined) restored.interpreter = env.exec_interpreter;
|
|
158
|
-
for (const key of RESTORABLE_PM2_KEYS) if (env[key] !== undefined) restored[key] = env[key];
|
|
159
|
-
return restored;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
async function rollback(previous, name) {
|
|
163
|
-
try { await pm2Call("delete", name); } catch {}
|
|
164
|
-
if (previous) await pm2Call("start", previous);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
export async function runOpsInstall(opts = {}) {
|
|
168
|
-
const configPath = path.resolve(requiredString(opts.config, "--config"));
|
|
169
|
-
const config = loadOpsInstallConfig(configPath);
|
|
170
|
-
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
171
|
-
const spec = buildOpsServiceSpec({ config, rootDir });
|
|
172
|
-
const endpoint = `http://127.0.0.1:${config.port}`;
|
|
173
|
-
if (opts.dryRun) {
|
|
174
|
-
process.stdout.write(`${JSON.stringify({ action: "install", dry_run: true, platform: process.platform, endpoint, pm2_name: config.pm2Name, policy_file: configPath, operations: Object.keys(config.applications).sort() }, null, 2)}\n`);
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
|
-
await new Promise((resolve, reject) => pm2.connect(false, (error) => error ? reject(error) : resolve()));
|
|
178
|
-
let result;
|
|
179
|
-
let previous = null;
|
|
180
|
-
try {
|
|
181
|
-
const existing = (await pm2Call("list")).find((entry) => (entry.name || entry.pm2_env?.name) === config.pm2Name);
|
|
182
|
-
// PM2 rejects an inline `env` object on restart-by-name. Replacing only
|
|
183
|
-
// this dedicated control-plane process is the supported way to apply a
|
|
184
|
-
// changed server policy without touching any managed application.
|
|
185
|
-
if (existing && !ownedService(existing, spec)) throw new Error(`refusing to replace non-installer PM2 process: ${config.pm2Name}`);
|
|
186
|
-
previous = existing ? previousSpec(existing) : null;
|
|
187
|
-
if (existing) await pm2Call("delete", config.pm2Name);
|
|
188
|
-
await pm2Call("start", spec);
|
|
189
|
-
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
190
|
-
try {
|
|
191
|
-
result = await probe(endpoint);
|
|
192
|
-
const current = (await pm2Call("list")).find((entry) => (entry.name || entry.pm2_env?.name) === config.pm2Name);
|
|
193
|
-
const pid = current?.pid ?? current?.pm2_env?.pm_pid;
|
|
194
|
-
const status = current?.pm2_env?.status ?? current?.status;
|
|
195
|
-
if (status !== "online" || !Number.isInteger(pid) || result.process_id !== pid || result.listening_port !== config.port) {
|
|
196
|
-
throw new Error("new PM2 process does not own the verified control-plane endpoint");
|
|
197
|
-
}
|
|
198
|
-
break;
|
|
199
|
-
} catch (error) {
|
|
200
|
-
if (attempt === 19) throw error;
|
|
201
|
-
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
} catch (error) {
|
|
205
|
-
await rollback(previous, config.pm2Name);
|
|
206
|
-
throw error;
|
|
207
|
-
} finally {
|
|
208
|
-
await new Promise((resolve) => pm2.disconnect(() => resolve()));
|
|
209
|
-
}
|
|
210
|
-
process.stdout.write(`${JSON.stringify({ action: "install", platform: process.platform, endpoint, pm2_name: config.pm2Name, policy_file: configPath, health_status: result.health_status, bearer_gate_status: result.bearer_gate_status }, null, 2)}\n`);
|
|
211
|
-
}
|