vetra 0.1.0.pre.alpha.4 → 0.1.0.pre.alpha.5
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.
- checksums.yaml +4 -4
- data/lib/vetra/cli.rb +17 -2
- data/lib/vetra/graph/compiler.rb +12 -3
- data/lib/vetra/sql/authorization.rb +192 -0
- data/lib/vetra/version.rb +1 -1
- data/lib/vetra.rb +1 -0
- metadata +13 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1d2dd87928b914db9b1144b0f5e17c356c7ad3aaaf473fe3c58f08fd37400527
|
|
4
|
+
data.tar.gz: a6236f38b853eb0c37b2a99f6087cb6150655c4f0f60a1ab74c94e4cb17b7727
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c2006e7dbef28ac026b4cfda4babbe68809b96700a66b4e106d5e8ede0bd18c8c580d1a38f94f1dea737bd1bf0125906608ac4c91900f9436b4e0c4b15eff979
|
|
7
|
+
data.tar.gz: 39938ef9de395d7bc988ecbe3a916aabd86e8da5f49bb662edf06c53c20fdbaaf03d789aeed4c9580ad9f62354dee4dea911619cf0816f298f320ca51b5566c6
|
data/lib/vetra/cli.rb
CHANGED
|
@@ -6,20 +6,35 @@ module Vetra
|
|
|
6
6
|
def self.run(arguments, out: $stdout, err: $stderr)
|
|
7
7
|
options = { root: Dir.pwd, sources: false, format: nil, fail_on_breaking: false }
|
|
8
8
|
parser = OptionParser.new do |flags|
|
|
9
|
-
flags.banner = "Uso: vetra graph:build|graph:check|graph:link [--root diretório]\n vetra graph:validate [arquivo] [--root diretório] [--sources]\n vetra graph:diff ANTERIOR POSTERIOR [--root diretório] [--format text|json] [--fail-on-breaking]"
|
|
9
|
+
flags.banner = "Uso: vetra graph:build|graph:check|graph:link [--root diretório]\n vetra graph:validate [arquivo] [--root diretório] [--sources]\n vetra graph:diff ANTERIOR POSTERIOR [--root diretório] [--format text|json] [--fail-on-breaking]\n vetra db:authz:sql [--root diretório] [--sql-schema nome] [--owner-role nome] [--app-role nome]"
|
|
10
10
|
flags.on('--root PATH', 'Raiz da aplicação') { |value| options[:root] = value }
|
|
11
11
|
flags.on('--sources', 'Também verificar manifesto em graph:validate') { options[:sources] = true }
|
|
12
12
|
flags.on('--format FORMAT', %w[text json], 'Formato de graph:diff: text ou json') { |value| options[:format] = value }
|
|
13
13
|
flags.on('--fail-on-breaking', 'Sair com código 3 se o diff apontar quebra potencial') { options[:fail_on_breaking] = true }
|
|
14
|
+
flags.on('--sql-schema NAME', 'Schema novo do SQL de revisão de autorização') { |value| options[:schema] = value }
|
|
15
|
+
flags.on('--owner-role NAME', 'Papel proprietário já existente') { |value| options[:owner_role] = value }
|
|
16
|
+
flags.on('--app-role NAME', 'Papel da aplicação já existente') { |value| options[:app_role] = value }
|
|
14
17
|
flags.on('-h', '--help', 'Mostrar ajuda') { out.puts(flags); return 0 }
|
|
15
18
|
flags.on('--version', 'Mostrar versão do protótipo') { out.puts(VERSION); return 0 }
|
|
16
19
|
end
|
|
17
20
|
args = parser.parse(arguments.dup)
|
|
18
21
|
command = args.shift
|
|
19
|
-
unless %w[graph:build graph:check graph:validate graph:diff graph:link].include?(command)
|
|
22
|
+
unless %w[graph:build graph:check graph:validate graph:diff graph:link db:authz:sql].include?(command)
|
|
20
23
|
err.puts(parser)
|
|
21
24
|
return 2
|
|
22
25
|
end
|
|
26
|
+
sql_options = options.slice(:schema, :owner_role, :app_role)
|
|
27
|
+
if command == 'db:authz:sql'
|
|
28
|
+
unless args.empty? && !options[:sources] && !options[:format] && !options[:fail_on_breaking]
|
|
29
|
+
raise OptionParser::InvalidArgument, 'db:authz:sql aceita somente --root e opções de schema/papéis'
|
|
30
|
+
end
|
|
31
|
+
result = Graph::Compiler.new(root: options[:root]).read_artifact
|
|
32
|
+
generated = SQL::Authorization.new(result.document, **sql_options).generate
|
|
33
|
+
generated.warnings.each { |diagnostic| err.puts(diagnostic) }
|
|
34
|
+
out.write(generated.sql)
|
|
35
|
+
return 0
|
|
36
|
+
end
|
|
37
|
+
raise OptionParser::InvalidArgument, 'opções de SQL exigem db:authz:sql' unless sql_options.empty?
|
|
23
38
|
if command == 'graph:diff'
|
|
24
39
|
raise OptionParser::InvalidArgument, 'graph:diff exige duas entradas e não aceita --sources' unless args.size == 2 && !options[:sources]
|
|
25
40
|
source = Graph::DiffSource.new(root: options[:root])
|
data/lib/vetra/graph/compiler.rb
CHANGED
|
@@ -65,10 +65,10 @@ module Vetra
|
|
|
65
65
|
root.join('.vetra/graph.json')
|
|
66
66
|
end
|
|
67
67
|
|
|
68
|
-
def
|
|
68
|
+
def read_artifact
|
|
69
69
|
bytes = artifact_bytes
|
|
70
70
|
document = Canonical.parse(bytes)
|
|
71
|
-
Validator.new.validate!(document)
|
|
71
|
+
warnings = Validator.new.validate!(document)
|
|
72
72
|
unless bytes == "#{Canonical.dump(document)}\n"
|
|
73
73
|
raise Error.single('graph_not_canonical', 'O artefato deve usar JSON canônico seguido de LF; execute graph:build no pipeline.')
|
|
74
74
|
end
|
|
@@ -78,7 +78,16 @@ module Vetra
|
|
|
78
78
|
end
|
|
79
79
|
manifest = Manifest.new(root)
|
|
80
80
|
manifest.verify!(document)
|
|
81
|
-
|
|
81
|
+
unless artifact_bytes == bytes
|
|
82
|
+
raise Error.single('graph_changed', 'O artefato mudou durante a leitura; use um pacote imutável.')
|
|
83
|
+
end
|
|
84
|
+
Data.freeze(Result.new(document: document, warnings: warnings, implementation_ids: []))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def link
|
|
88
|
+
result = read_artifact
|
|
89
|
+
document, bytes = result.document, result.bytes
|
|
90
|
+
manifest = Manifest.new(root)
|
|
82
91
|
|
|
83
92
|
# Registration is needed to recreate Ruby closures in this process. The
|
|
84
93
|
# normalized declaration is an audit only: it never replaces the artifact.
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
require 'digest'
|
|
2
|
+
|
|
3
|
+
module Vetra
|
|
4
|
+
module SQL
|
|
5
|
+
class Authorization
|
|
6
|
+
Result = Struct.new(:sql, :tables, :warnings, keyword_init: true)
|
|
7
|
+
|
|
8
|
+
def initialize(document, schema: 'vetra', owner_role: 'vetra_owner', app_role: 'vetra_app')
|
|
9
|
+
@document = Graph::Data.copy(document)
|
|
10
|
+
@schema, @owner, @app = schema, owner_role, app_role
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def generate
|
|
14
|
+
warnings = Graph::Validator.new.validate!(@document)
|
|
15
|
+
[@schema, @owner, @app].each { |name| identifier!(name) }
|
|
16
|
+
if @owner == @app || @schema.start_with?('pg_') || @schema == 'information_schema'
|
|
17
|
+
raise Graph::Error.single('authz_roles', 'Use papéis distintos e um schema de infraestrutura fora dos schemas reservados.')
|
|
18
|
+
end
|
|
19
|
+
tenant = @document.dig('app', 'tenant', 'entity', 'id')
|
|
20
|
+
raise Graph::Error.single('authz_tenant', 'O bootstrap de revisão exige tenant explícito; aplicações globais ainda não são suportadas.', pointer: '/app/tenant') unless tenant
|
|
21
|
+
entities = @document['nodes'].select { |node| node['kind'] == 'entity' }.to_h { |node| [node['id'], node] }
|
|
22
|
+
@root = entities.fetch(tenant)
|
|
23
|
+
ids = [tenant] + @document['nodes'].select { |node| node['kind'] == 'policy' }.flat_map { |node| node['reads'].map { |ref| ref['id'] } }
|
|
24
|
+
tables = ids.uniq.sort.map do |id|
|
|
25
|
+
entity = entities.fetch(id)
|
|
26
|
+
column = if id == tenant
|
|
27
|
+
'id'
|
|
28
|
+
elsif entity.dig('tenant', 'entity', 'id') == tenant
|
|
29
|
+
entity.dig('tenant', 'field')
|
|
30
|
+
end
|
|
31
|
+
unless column
|
|
32
|
+
raise Graph::Error.single('authz_scope', "#{id} é uma dependência global de autorização; falta definir sua chave de revisão.", source: entity['source'])
|
|
33
|
+
end
|
|
34
|
+
storage = entity['storage']
|
|
35
|
+
[storage['schema'], storage['table'], column].each { |name| identifier!(name) }
|
|
36
|
+
{ 'entity' => id, 'schema' => storage['schema'], 'table' => storage['table'], 'tenant_column' => column }
|
|
37
|
+
end
|
|
38
|
+
if @root.dig('storage', 'primary_key') != ['id'] || tables.any? { |table| table['schema'] == @schema } || tables.map { |table| table.values_at('schema', 'table') }.uniq.size != tables.size
|
|
39
|
+
raise Graph::Error.single('authz_storage', 'Use raiz com PK id, tabelas físicas distintas e schema de infraestrutura separado das tabelas da aplicação.')
|
|
40
|
+
end
|
|
41
|
+
@tables = tables.sort_by { |table| table.values_at('schema', 'table') }
|
|
42
|
+
sql = [header, 'BEGIN;', "SET LOCAL lock_timeout = '3s';", 'SET LOCAL search_path = pg_catalog, pg_temp;', role_guards,
|
|
43
|
+
"LOCK TABLE #{@tables.map { |table| "ONLY #{relation(table)}" }.join(', ')} IN SHARE ROW EXCLUSIVE MODE;",
|
|
44
|
+
storage_guards, infrastructure, *@tables.map { |table| triggers(table) }, privilege_guards,
|
|
45
|
+
"-- Varredura da raiz do tenant para preencher revisões iniciais.",
|
|
46
|
+
"INSERT INTO #{revisions} (tenant_id, revision) SELECT #{quote('id')}::text, 0 FROM #{root_relation};", 'COMMIT;', ''].join("\n")
|
|
47
|
+
Graph::Data.freeze(Result.new(sql: sql, tables: @tables, warnings: warnings))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def identifier!(name)
|
|
53
|
+
unless name.is_a?(String) && name.match?(/\A[a-z][a-z0-9_]*\z/) && name.bytesize <= 63
|
|
54
|
+
raise Graph::Error.single('authz_identifier', 'Identificadores SQL exigem letras minúsculas, números ou sublinhado, começam por letra e têm até 63 bytes.')
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def quote(name)
|
|
59
|
+
%Q{"#{name}"}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def literal(value)
|
|
63
|
+
"'#{value.gsub("'", "''")}'"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def relation(table)
|
|
67
|
+
"#{quote(table['schema'])}.#{quote(table['table'])}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def root_relation
|
|
71
|
+
relation(@root['storage'])
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def revisions
|
|
75
|
+
"#{quote(@schema)}.#{quote('authz_revisions')}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def header
|
|
79
|
+
(["-- Vetra: bootstrap de revisão de autorização; SQL para revisão, sem aplicação automática.",
|
|
80
|
+
"-- Grafo #{@document['digest']}; aplicação #{JSON.generate(@document.dig('app', 'name'))}.",
|
|
81
|
+
'-- Requer PostgreSQL 17+, tabelas existentes, papéis existentes e schema de infraestrutura novo.',
|
|
82
|
+
'-- Instalação: bloqueia escritas com SHARE ROW EXCLUSIVE; lock_timeout = 3s; lê os IDs da raiz.'] +
|
|
83
|
+
@tables.map { |table| "-- #{table['entity']}: #{relation(table)}; tenant pela coluna #{quote(table['tenant_column'])}." }).join("\n")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def role_guards
|
|
87
|
+
<<~SQL
|
|
88
|
+
DO $vetra_roles$
|
|
89
|
+
BEGIN
|
|
90
|
+
IF current_setting('server_version_num')::integer < 170000 THEN
|
|
91
|
+
RAISE EXCEPTION 'Vetra exige PostgreSQL 17 ou superior';
|
|
92
|
+
END IF;
|
|
93
|
+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = #{literal(@owner)} AND NOT rolcanlogin AND NOT rolsuper AND NOT rolbypassrls AND NOT rolcreaterole) THEN
|
|
94
|
+
RAISE EXCEPTION 'Proprietário deve existir, sem LOGIN, SUPERUSER, BYPASSRLS ou CREATEROLE';
|
|
95
|
+
END IF;
|
|
96
|
+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = #{literal(@app)} AND NOT rolsuper AND NOT rolbypassrls AND NOT rolcreaterole) THEN
|
|
97
|
+
RAISE EXCEPTION 'Aplicação deve existir, sem SUPERUSER, BYPASSRLS ou CREATEROLE';
|
|
98
|
+
END IF;
|
|
99
|
+
IF pg_has_role(#{literal(@app)}, #{literal(@owner)}, 'MEMBER') THEN
|
|
100
|
+
RAISE EXCEPTION 'Aplicação não pode pertencer ao papel proprietário';
|
|
101
|
+
END IF;
|
|
102
|
+
END $vetra_roles$;
|
|
103
|
+
SQL
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def storage_guards
|
|
107
|
+
checks = @tables.map do |table|
|
|
108
|
+
path = literal(relation(table))
|
|
109
|
+
column = literal(table['tenant_column'])
|
|
110
|
+
<<~SQL
|
|
111
|
+
IF NOT EXISTS (SELECT 1 FROM pg_class WHERE oid = #{path}::regclass AND relkind = 'r' AND NOT relispartition)
|
|
112
|
+
OR EXISTS (SELECT 1 FROM pg_inherits WHERE inhrelid = #{path}::regclass OR inhparent = #{path}::regclass) THEN
|
|
113
|
+
RAISE EXCEPTION 'Tabelas particionadas ou herdadas não são suportadas: %', #{path};
|
|
114
|
+
END IF;
|
|
115
|
+
IF NOT EXISTS (SELECT 1 FROM pg_attribute a JOIN pg_attribute root ON root.attrelid = #{literal(root_relation)}::regclass AND root.attname = 'id'
|
|
116
|
+
WHERE a.attrelid = #{path}::regclass AND a.attname = #{column} AND NOT a.attisdropped AND NOT root.attisdropped
|
|
117
|
+
AND a.attnotnull AND root.attnotnull AND a.atttypid = root.atttypid AND a.atttypmod = root.atttypmod
|
|
118
|
+
AND root.atttypid IN ('uuid'::regtype, 'text'::regtype, 'varchar'::regtype, 'bpchar'::regtype, 'int2'::regtype, 'int4'::regtype, 'int8'::regtype)) THEN
|
|
119
|
+
RAISE EXCEPTION 'Coluna de tenant ausente, nula ou de tipo incompatível: %.%', #{path}, #{column};
|
|
120
|
+
END IF;
|
|
121
|
+
IF EXISTS (SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.oid = #{path}::regclass
|
|
122
|
+
AND (pg_has_role(#{literal(@app)}, c.relowner, 'MEMBER') OR pg_has_role(#{literal(@app)}, n.nspowner, 'MEMBER'))) THEN
|
|
123
|
+
RAISE EXCEPTION 'Aplicação não pode possuir tabela protegida ou seu schema: %', #{path};
|
|
124
|
+
END IF;
|
|
125
|
+
SQL
|
|
126
|
+
end
|
|
127
|
+
<<~SQL
|
|
128
|
+
DO $vetra_tables$
|
|
129
|
+
BEGIN
|
|
130
|
+
IF NOT EXISTS (SELECT 1 FROM pg_index i JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attname = 'id'
|
|
131
|
+
WHERE i.indrelid = #{literal(root_relation)}::regclass AND i.indisprimary AND i.indnatts = 1 AND i.indkey[0] = a.attnum) THEN
|
|
132
|
+
RAISE EXCEPTION 'Raiz do tenant deve ter chave primária simples em id';
|
|
133
|
+
END IF;
|
|
134
|
+
#{checks.join}
|
|
135
|
+
END $vetra_tables$;
|
|
136
|
+
SQL
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def infrastructure
|
|
140
|
+
<<~SQL
|
|
141
|
+
CREATE SCHEMA #{quote(@schema)} AUTHORIZATION #{quote(@owner)};
|
|
142
|
+
CREATE TABLE #{revisions} (tenant_id text PRIMARY KEY, revision bigint NOT NULL CHECK (revision >= 0));
|
|
143
|
+
ALTER TABLE #{revisions} OWNER TO #{quote(@owner)};
|
|
144
|
+
REVOKE ALL ON #{revisions} FROM PUBLIC, #{quote(@app)};
|
|
145
|
+
GRANT USAGE ON SCHEMA #{quote(@schema)} TO #{quote(@app)};
|
|
146
|
+
GRANT SELECT ON #{revisions} TO #{quote(@app)};
|
|
147
|
+
SQL
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def triggers(table)
|
|
151
|
+
suffix = Digest::SHA256.hexdigest([@document.dig('app', 'name'), table['entity']].join("\0"))[0, 24]
|
|
152
|
+
column = quote(table['tenant_column'])
|
|
153
|
+
{
|
|
154
|
+
'insert' => ['NEW TABLE AS vt_new', "SELECT DISTINCT #{column}::text AS tenant_id FROM vt_new ORDER BY tenant_id"],
|
|
155
|
+
'delete' => ['OLD TABLE AS vt_old', "SELECT DISTINCT #{column}::text AS tenant_id FROM vt_old ORDER BY tenant_id"],
|
|
156
|
+
'update' => ['OLD TABLE AS vt_old NEW TABLE AS vt_new', "SELECT #{column}::text AS tenant_id FROM vt_old UNION SELECT #{column}::text AS tenant_id FROM vt_new ORDER BY tenant_id"]
|
|
157
|
+
}.map do |event, (references, tenants)|
|
|
158
|
+
name = quote("vt_authz_#{suffix}_#{event}")
|
|
159
|
+
function = "#{quote(@schema)}.#{name}"
|
|
160
|
+
<<~SQL
|
|
161
|
+
CREATE FUNCTION #{function}() RETURNS trigger LANGUAGE plpgsql
|
|
162
|
+
SECURITY DEFINER SET search_path = pg_catalog, pg_temp AS $vetra_authz$
|
|
163
|
+
DECLARE affected_tenant text;
|
|
164
|
+
BEGIN
|
|
165
|
+
FOR affected_tenant IN #{tenants} LOOP
|
|
166
|
+
INSERT INTO #{revisions} AS stored (tenant_id, revision) VALUES (affected_tenant, 1)
|
|
167
|
+
ON CONFLICT (tenant_id) DO UPDATE SET revision = stored.revision + 1;
|
|
168
|
+
PERFORM pg_notify('vetra_authz', affected_tenant);
|
|
169
|
+
END LOOP;
|
|
170
|
+
RETURN NULL;
|
|
171
|
+
END $vetra_authz$;
|
|
172
|
+
ALTER FUNCTION #{function}() OWNER TO #{quote(@owner)};
|
|
173
|
+
REVOKE ALL ON FUNCTION #{function}() FROM PUBLIC, #{quote(@app)};
|
|
174
|
+
CREATE TRIGGER #{name} AFTER #{event.upcase} ON #{relation(table)}
|
|
175
|
+
REFERENCING #{references} FOR EACH STATEMENT EXECUTE FUNCTION #{function}();
|
|
176
|
+
SQL
|
|
177
|
+
end.join + "REVOKE TRUNCATE, TRIGGER ON #{relation(table)} FROM PUBLIC, #{quote(@app)};\n"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def privilege_guards
|
|
181
|
+
checks = @tables.map do |table|
|
|
182
|
+
<<~SQL
|
|
183
|
+
IF has_table_privilege(#{literal(@app)}, #{literal(relation(table))}, 'TRUNCATE,TRIGGER') THEN
|
|
184
|
+
RAISE EXCEPTION 'Privilégios herdados de TRUNCATE/TRIGGER ainda permitem alterar a invalidação';
|
|
185
|
+
END IF;
|
|
186
|
+
SQL
|
|
187
|
+
end
|
|
188
|
+
"DO $vetra_privileges$\nBEGIN\n#{checks.join}END $vetra_privileges$;\n"
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
data/lib/vetra/version.rb
CHANGED
data/lib/vetra.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: vetra
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.0.pre.alpha.
|
|
4
|
+
version: 0.1.0.pre.alpha.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Denilson Silva
|
|
@@ -85,6 +85,11 @@ dependencies:
|
|
|
85
85
|
- - "~>"
|
|
86
86
|
- !ruby/object:Gem::Version
|
|
87
87
|
version: '13.2'
|
|
88
|
+
description: Protótipo da fase 0 do Vetra, um framework web para Ruby 4 e PostgreSQL
|
|
89
|
+
que descreve a aplicação como um grafo verificável. Compila o DSL de entidades,
|
|
90
|
+
comandos, consultas, permissões e eventos numa IR versionada, valida o artefato,
|
|
91
|
+
compara versões e vincula os blocos Ruby. Ainda não executa operações nem serve
|
|
92
|
+
HTTP.
|
|
88
93
|
executables:
|
|
89
94
|
- vetra
|
|
90
95
|
extensions: []
|
|
@@ -117,10 +122,16 @@ files:
|
|
|
117
122
|
- lib/vetra/graph/registry.rb
|
|
118
123
|
- lib/vetra/graph/schema.rb
|
|
119
124
|
- lib/vetra/graph/validator.rb
|
|
125
|
+
- lib/vetra/sql/authorization.rb
|
|
120
126
|
- lib/vetra/version.rb
|
|
127
|
+
homepage: https://github.com/vetra-framework/vetra
|
|
121
128
|
licenses:
|
|
122
129
|
- MIT
|
|
123
|
-
metadata:
|
|
130
|
+
metadata:
|
|
131
|
+
source_code_uri: https://github.com/vetra-framework/vetra
|
|
132
|
+
documentation_uri: https://github.com/vetra-framework/vetra/blob/main/docs/fase-0.md
|
|
133
|
+
bug_tracker_uri: https://github.com/vetra-framework/vetra/issues
|
|
134
|
+
rubygems_mfa_required: 'true'
|
|
124
135
|
rdoc_options: []
|
|
125
136
|
require_paths:
|
|
126
137
|
- lib
|