k9o 1.0.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.

Potentially problematic release.


This version of k9o might be problematic. Click here for more details.

@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ module K9o
4
+ # Migration lifecycle namespace — k9o.migrations.*.
5
+ #
6
+ # TYPE MODEL (ch13 §2.2, cemented f21b3c15 — Ruby is TIER-3):
7
+ #
8
+ # Ruby keeps a RUNTIME HASH PASS-THROUGH for migration payloads (the
9
+ # gateway is the fail-closed source of truth; SDK types are ergonomics +
10
+ # serialization, never validation). The structured shape is documented
11
+ # below AND statically checkable via the shipped RBS signatures in
12
+ # sig/k9o/migrations.rbs (steep/typeprof), per the tier-3 contract:
13
+ # "documented + statically-checkable pass-through".
14
+ #
15
+ # The control-plane request/submit shape (the +migrations+ array) is the
16
+ # FIXED migration Operation union — control-plane, so it is TYPED. Build
17
+ # well-formed operation hashes with the keyword-arg helpers on
18
+ # K9o::Migrations::Build (idiomatic Ruby "typed" constructors) which emit
19
+ # exactly the wire JSON; or hand-author the hash against the documented
20
+ # shape. The closed-union property (exactly these 7 ops) is enforced at
21
+ # wire + server validation for all tiers.
22
+ #
23
+ # WIRE SHAPE (snake_case keys, matches packages/core/src/migrations.ts):
24
+ #
25
+ # migration = {
26
+ # 'version' => Integer, # NOTE: integer, not a "001" string
27
+ # 'slug' => String,
28
+ # 'up' => [Operation, ...],
29
+ # 'down' => [Operation, ...],
30
+ # 'policy' => MigrationPolicy, # optional
31
+ # }
32
+ #
33
+ # Operation — KEY-TAGGED union, EXACTLY ONE key, one of these 7 (alter_table
34
+ # is the batched column mutation, live in the gateway runner; rename_column
35
+ # remains a pending future op, intentionally absent):
36
+ # { 'create_table' => { 'name' => String,
37
+ # 'columns' => [ColumnSpec, ...],
38
+ # 'indexes' => [IndexSpec, ...] } } # indexes optional
39
+ # { 'drop_table' => String }
40
+ # { 'add_column' => { 'table' => String, 'column' => ColumnSpec } }
41
+ # { 'drop_column' => { 'table' => String, 'column' => String } }
42
+ # { 'create_index' => { 'table' => String, 'index' => IndexSpec } }
43
+ # { 'drop_index' => { 'name' => String } }
44
+ #
45
+ # ColumnSpec = {
46
+ # 'name' => String,
47
+ # 'type' => String, # parser-owned vocabulary; NOT an enum
48
+ # 'primary_key' => true|false, # optional
49
+ # 'not_null' => true|false, # optional
50
+ # 'unique' => true|false, # optional
51
+ # 'default' => String, # optional
52
+ # 'references' => ColumnReferences, # optional
53
+ # 'encrypted' => true | { 'store' => 'inline'|'vault',
54
+ # 'searchable' => true|false }, # optional
55
+ # # NOTE: 'searchable' is NESTED inside the object form,
56
+ # # NOT a sibling of the column.
57
+ # 'blind_indexed' => true|false, # optional (C-mode equality lookup)
58
+ # }
59
+ #
60
+ # IndexSpec = { 'name' => String (optional), 'columns' => [String, ...],
61
+ # 'unique' => true|false (optional) }
62
+ # ColumnReferences = { 'collection' => String, 'on' => String,
63
+ # 'on_delete' => 'CASCADE'|'RESTRICT'|'SET NULL'|'NO ACTION' (opt),
64
+ # 'on_update' => same (opt) }
65
+ #
66
+ # MigrationPolicy = { 'collections' => { String => CollectionPolicy },
67
+ # 'storage' => { 'buckets' => { String => StorageBucketPolicy } } }
68
+ # CollectionPolicy = { 'references' => [RelationshipSpec, ...],
69
+ # 'actions' => { 'select'|'insert'|'update'|'delete' => ActionRule } }
70
+ # RelationshipSpec = { 'collection' => String, 'on' => String, 'as' => String,
71
+ # 'type' => 'inner'|'left' (opt) }
72
+ # ActionRule = { 'rule' => any (opt) }
73
+ # StorageBucketPolicy = { 'public' => true|false (opt),
74
+ # 'actions' => { 'read'|'upload'|'delete'|'list' => ActionRule } }
75
+ #
76
+ # RESPONSE records (returned in the envelope data, pass-through hashes):
77
+ # AppliedMigration = { 'version' => Integer, 'slug' => String,
78
+ # 'checksum' => String, 'appliedAt' => String }
79
+ # PendingMigration = { 'pendingId' => String, 'version' => Integer,
80
+ # 'slug' => String, 'checksum' => String,
81
+ # 'submittedAt' => String, 'submittedBy' => String }
82
+ class Migrations
83
+ def initialize(config)
84
+ @config = config
85
+ end
86
+
87
+ # Apply migrations immediately (PROJECT_ADMIN minimum).
88
+ # +migrations+ is an array of migration hashes (see the shape above; build
89
+ # operation hashes with K9o::Migrations::Build).
90
+ def apply(migrations)
91
+ slug = require_project_slug
92
+ HttpTransport.request(config: @config, method: 'POST',
93
+ path: UrlGrammar.project_path(slug, 'migrations/apply'), body: { 'migrations' => migrations })
94
+ end
95
+
96
+ def list
97
+ slug = require_project_slug
98
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, 'migrations'))
99
+ end
100
+
101
+ def get(version)
102
+ slug = require_project_slug
103
+ HttpTransport.request(config: @config, method: 'GET',
104
+ path: UrlGrammar.project_path(slug, "migrations/#{enc(version)}"))
105
+ end
106
+
107
+ # Queue migrations for maker-checker approval per ch06 §8.
108
+ def queue(migrations)
109
+ slug = require_project_slug
110
+ HttpTransport.request(config: @config, method: 'POST',
111
+ path: UrlGrammar.project_path(slug, 'migrations/queue'), body: { 'migrations' => migrations })
112
+ end
113
+
114
+ def list_pending
115
+ slug = require_project_slug
116
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, 'migrations/pending'))
117
+ end
118
+
119
+ def apply_pending(pending_id)
120
+ slug = require_project_slug
121
+ HttpTransport.request(config: @config, method: 'POST',
122
+ path: UrlGrammar.project_path(slug, "migrations/pending/#{enc(pending_id)}/apply"))
123
+ end
124
+
125
+ def reject_pending(pending_id, reason: nil)
126
+ slug = require_project_slug
127
+ body = reason ? { 'reason' => reason } : nil
128
+ HttpTransport.request(config: @config, method: 'POST',
129
+ path: UrlGrammar.project_path(slug, "migrations/pending/#{enc(pending_id)}/reject"), body: body)
130
+ end
131
+
132
+ def delete_pending(pending_id)
133
+ slug = require_project_slug
134
+ HttpTransport.request(config: @config, method: 'DELETE',
135
+ path: UrlGrammar.project_path(slug, "migrations/pending/#{enc(pending_id)}"))
136
+ end
137
+
138
+ # Typed keyword-arg constructors for the FIXED control-plane migration
139
+ # Operation union + its sub-shapes. Each returns the exact wire hash; nil
140
+ # keyword args are omitted so the emitted JSON matches the canonical
141
+ # `omitempty`/optional shape (packages/core/src/migrations.ts,
142
+ # packages/go/migrations.go). These are the tier-3 "typed request object"
143
+ # surface — the RBS in sig/k9o/migrations.rbs gives them static checking.
144
+ module Build
145
+ module_function
146
+
147
+ # --- ColumnSpec --------------------------------------------------------
148
+ # +encrypted+: pass `true` for the inline shorthand, or the object hash
149
+ # `{ 'store' => 'inline'|'vault', 'searchable' => bool }`. Prefer the
150
+ # column_encryption helper to build the object form correctly (searchable
151
+ # NESTED, never a sibling).
152
+ def column(name:, type:, primary_key: nil, not_null: nil, unique: nil,
153
+ default: nil, references: nil, encrypted: nil, blind_indexed: nil)
154
+ h = { 'name' => name, 'type' => type }
155
+ h['primary_key'] = primary_key unless primary_key.nil?
156
+ h['not_null'] = not_null unless not_null.nil?
157
+ h['unique'] = unique unless unique.nil?
158
+ h['default'] = default unless default.nil?
159
+ h['references'] = references unless references.nil?
160
+ h['encrypted'] = encrypted unless encrypted.nil?
161
+ h['blind_indexed'] = blind_indexed unless blind_indexed.nil?
162
+ h
163
+ end
164
+
165
+ # Object form of the `encrypted` union. The bare `true` shorthand needs no
166
+ # helper — just pass `encrypted: true`. Here +searchable+ is NESTED inside
167
+ # the returned object, per the cemented shape.
168
+ def column_encryption(store:, searchable: nil)
169
+ h = { 'store' => store }
170
+ h['searchable'] = searchable unless searchable.nil?
171
+ h
172
+ end
173
+
174
+ def references(collection:, on:, on_delete: nil, on_update: nil)
175
+ h = { 'collection' => collection, 'on' => on }
176
+ h['on_delete'] = on_delete unless on_delete.nil?
177
+ h['on_update'] = on_update unless on_update.nil?
178
+ h
179
+ end
180
+
181
+ def index(columns:, name: nil, unique: nil)
182
+ h = { 'columns' => columns }
183
+ h['name'] = name unless name.nil?
184
+ h['unique'] = unique unless unique.nil?
185
+ h
186
+ end
187
+
188
+ # --- Operation union (exactly one key per op) --------------------------
189
+ def create_table(name:, columns:, indexes: nil)
190
+ op = { 'name' => name, 'columns' => columns }
191
+ op['indexes'] = indexes unless indexes.nil?
192
+ { 'create_table' => op }
193
+ end
194
+
195
+ def drop_table(name)
196
+ { 'drop_table' => name }
197
+ end
198
+
199
+ def add_column(table:, column:)
200
+ { 'add_column' => { 'table' => table, 'column' => column } }
201
+ end
202
+
203
+ def drop_column(table:, column:)
204
+ { 'drop_column' => { 'table' => table, 'column' => column } }
205
+ end
206
+
207
+ # alter_table — BATCHED column mutation: one op carries optional
208
+ # add_columns + drop_columns (not separate ops). add_columns entries are
209
+ # full column_spec; drop_columns is column names. Live in the gateway
210
+ # runner.
211
+ def alter_table(name:, add_columns: nil, drop_columns: nil)
212
+ op = { 'name' => name }
213
+ op['add_columns'] = add_columns unless add_columns.nil?
214
+ op['drop_columns'] = drop_columns unless drop_columns.nil?
215
+ { 'alter_table' => op }
216
+ end
217
+
218
+ def create_index(table:, index:)
219
+ { 'create_index' => { 'table' => table, 'index' => index } }
220
+ end
221
+
222
+ def drop_index(name:)
223
+ { 'drop_index' => { 'name' => name } }
224
+ end
225
+
226
+ # --- Migration ---------------------------------------------------------
227
+ def migration(version:, slug:, up:, down:, policy: nil)
228
+ h = { 'version' => version, 'slug' => slug, 'up' => up, 'down' => down }
229
+ h['policy'] = policy unless policy.nil?
230
+ h
231
+ end
232
+ end
233
+
234
+ private
235
+
236
+ def require_project_slug
237
+ Studio.require_project_slug(@config, 'migrations')
238
+ end
239
+
240
+ def enc(s)
241
+ ::URI.encode_www_form_component(s)
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module K9o
6
+ # Minimal multipart/form-data encoder (stdlib only). Parts are hashes:
7
+ # { name: 'path', value: 'a.txt' } # plain field
8
+ # { name: 'file', value: bytes, filename: 'a.txt', # file part
9
+ # content_type: 'text/plain' }
10
+ # Values are sent as given, byte for byte (a String is its own bytes).
11
+ module Multipart
12
+ module_function
13
+
14
+ # Returns [body (binary String), content_type header value].
15
+ def encode(parts)
16
+ payload = parts.map { |p| p[:value].to_s.b }
17
+ boundary = nil
18
+ loop do
19
+ boundary = "----k9o-#{SecureRandom.hex(16)}"
20
+ break unless payload.any? { |v| v.include?(boundary) }
21
+ end
22
+
23
+ body = String.new(encoding: Encoding::BINARY)
24
+ parts.each_with_index do |p, i|
25
+ disposition = %(form-data; name="#{escape(p[:name])}")
26
+ disposition += %(; filename="#{escape(p[:filename])}") if p[:filename]
27
+ body << "--#{boundary}\r\n".b
28
+ body << "Content-Disposition: #{disposition}\r\n".b
29
+ body << "Content-Type: #{p[:content_type]}\r\n".b if p[:content_type]
30
+ body << "\r\n".b
31
+ body << payload[i]
32
+ body << "\r\n".b
33
+ end
34
+ body << "--#{boundary}--\r\n".b
35
+ [body, "multipart/form-data; boundary=#{boundary}"]
36
+ end
37
+
38
+ def escape(value)
39
+ value.to_s.gsub('"', '%22').gsub("\r", '%0D').gsub("\n", '%0A')
40
+ end
41
+ end
42
+ end
data/lib/k9o/org.rb ADDED
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ module K9o
4
+ # Org-plane bound namespace — k9o.org.*. Singular
5
+ # bound-org namespace (distinct from k9o.orgs collection): members /
6
+ # groups / smtp / audit_log / gate (roles + idp_providers) + default_llm.
7
+ # Org-plane URL grammar per v2.5.68 §5.4 routes against the org subdomain
8
+ # without the @<projectSlug>/ prefix.
9
+ class Org
10
+ attr_reader :members, :groups, :smtp, :audit_log, :gate, :default_llm
11
+
12
+ def initialize(config)
13
+ @config = config
14
+ @members = Members.new(config)
15
+ @groups = Groups.new(config)
16
+ @smtp = Smtp.new(config)
17
+ @audit_log = AuditLog.new(config)
18
+ @gate = Gate.new(config)
19
+ @default_llm = DefaultLlm.new(config)
20
+ end
21
+
22
+ # Read the current org (slug + name + status + settings).
23
+ def get
24
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('org'))
25
+ end
26
+
27
+ def self.enc(str)
28
+ ::URI.encode_www_form_component(str)
29
+ end
30
+
31
+ # k9o.org.members.*
32
+ class Members
33
+ def initialize(config)
34
+ @config = config
35
+ end
36
+
37
+ def list(status: nil)
38
+ q = status ? { 'status' => status } : nil
39
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('members'), query: q)
40
+ end
41
+
42
+ def invite(email:, role:)
43
+ HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.org_path('members'),
44
+ body: { 'email' => email, 'role' => role })
45
+ end
46
+
47
+ def get(user_id)
48
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path("members/#{Org.enc(user_id)}"))
49
+ end
50
+
51
+ def remove(user_id)
52
+ HttpTransport.request(config: @config, method: 'DELETE', path: UrlGrammar.org_path("members/#{Org.enc(user_id)}"))
53
+ end
54
+ end
55
+
56
+ # k9o.org.groups.*
57
+ class Groups
58
+ def initialize(config)
59
+ @config = config
60
+ end
61
+
62
+ def list
63
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('groups'))
64
+ end
65
+
66
+ def create(name:)
67
+ HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.org_path('groups'), body: { 'name' => name })
68
+ end
69
+
70
+ def get(group_id)
71
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path("groups/#{Org.enc(group_id)}"))
72
+ end
73
+
74
+ def delete(group_id)
75
+ HttpTransport.request(config: @config, method: 'DELETE', path: UrlGrammar.org_path("groups/#{Org.enc(group_id)}"))
76
+ end
77
+ end
78
+
79
+ # k9o.org.audit_log.* (org-plane; distinct from project Audit).
80
+ class AuditLog
81
+ def initialize(config)
82
+ @config = config
83
+ end
84
+
85
+ def query(since: nil, until_: nil, type: nil, actor_id: nil, target_kind: nil, target_id: nil, limit: nil, cursor: nil)
86
+ q = {}
87
+ q['since'] = since if since
88
+ q['until'] = until_ if until_
89
+ q['type'] = type if type
90
+ q['actorId'] = actor_id if actor_id
91
+ q['targetKind'] = target_kind if target_kind
92
+ q['targetId'] = target_id if target_id
93
+ q['limit'] = limit if limit
94
+ q['cursor'] = cursor if cursor
95
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('audit-log'), query: q)
96
+ end
97
+ end
98
+
99
+ # k9o.org.gate.*
100
+ class Gate
101
+ attr_reader :roles, :idp_providers
102
+
103
+ def initialize(config)
104
+ @roles = Roles.new(config)
105
+ @idp_providers = IdpProviders.new(config)
106
+ end
107
+
108
+ # k9o.org.gate.roles.*
109
+ class Roles
110
+ def initialize(config)
111
+ @config = config
112
+ end
113
+
114
+ def list(user_id: nil, role: nil)
115
+ q = {}
116
+ q['userId'] = user_id if user_id
117
+ q['role'] = role if role
118
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('org-role-grants'), query: q)
119
+ end
120
+
121
+ def grant(user_id:, role:)
122
+ HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.org_path('org-role-grants'),
123
+ body: { 'userId' => user_id, 'role' => role })
124
+ end
125
+
126
+ def get(grant_id)
127
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path("org-role-grants/#{Org.enc(grant_id)}"))
128
+ end
129
+
130
+ def revoke(grant_id)
131
+ HttpTransport.request(config: @config, method: 'DELETE', path: UrlGrammar.org_path("org-role-grants/#{Org.enc(grant_id)}"))
132
+ end
133
+ end
134
+
135
+ # k9o.org.gate.idp_providers.*
136
+ class IdpProviders
137
+ def initialize(config)
138
+ @config = config
139
+ end
140
+
141
+ def list
142
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('idp-providers'))
143
+ end
144
+
145
+ def create(name:, kind:, config:)
146
+ HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.org_path('idp-providers'),
147
+ body: { 'name' => name, 'kind' => kind, 'config' => config })
148
+ end
149
+
150
+ def get(idp_id)
151
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path("idp-providers/#{Org.enc(idp_id)}"))
152
+ end
153
+
154
+ def configure(idp_id, patch)
155
+ HttpTransport.request(config: @config, method: 'PATCH',
156
+ path: UrlGrammar.org_path("idp-providers/#{Org.enc(idp_id)}"), body: patch)
157
+ end
158
+
159
+ def delete(idp_id)
160
+ HttpTransport.request(config: @config, method: 'DELETE', path: UrlGrammar.org_path("idp-providers/#{Org.enc(idp_id)}"))
161
+ end
162
+ end
163
+ end
164
+
165
+ # k9o.org.smtp.*
166
+ class Smtp
167
+ def initialize(config)
168
+ @config = config
169
+ end
170
+
171
+ def get
172
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('org/smtp'))
173
+ end
174
+
175
+ def set(host:, port:, username:, password:, from_email:, from_name:, encryption:, reply_to_email: nil)
176
+ smtp = {
177
+ 'host' => host, 'port' => port, 'username' => username, 'password' => password,
178
+ 'fromEmail' => from_email, 'fromName' => from_name, 'encryption' => encryption
179
+ }
180
+ smtp['replyToEmail'] = reply_to_email if reply_to_email
181
+ HttpTransport.request(config: @config, method: 'PATCH', path: UrlGrammar.org_path('org'), body: { 'smtpConfig' => smtp })
182
+ end
183
+
184
+ def test(to:, from_override: nil)
185
+ body = { 'to' => to }
186
+ body['fromOverride'] = from_override if from_override
187
+ HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.org_path('org/smtp/test-email'), body: body)
188
+ end
189
+
190
+ def unset
191
+ HttpTransport.request(config: @config, method: 'PATCH', path: UrlGrammar.org_path('org'), body: { 'smtpConfig' => nil })
192
+ end
193
+ end
194
+
195
+ # k9o.org.default_llm.* per ch07 §10.3 (ORG_ADMIN; tier-gated).
196
+ class DefaultLlm
197
+ def initialize(config)
198
+ @config = config
199
+ end
200
+
201
+ # Read the org-default LLM model id (nil when unset → mistral-7b).
202
+ def get
203
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('org/default-llm'))
204
+ end
205
+
206
+ # Set the org-default LLM (ORG_ADMIN; tier-gated per §10.1).
207
+ def set(model_id)
208
+ HttpTransport.request(config: @config, method: 'PATCH',
209
+ path: UrlGrammar.org_path('org/default-llm'), body: { 'modelId' => model_id })
210
+ end
211
+ end
212
+ end
213
+ end
data/lib/k9o/orgs.rb ADDED
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module K9o
4
+ # Org lifecycle namespace (collection-style) — k9o.orgs.*.
5
+ # Distinct from k9o.org (singular bound-org namespace).
6
+ class Orgs
7
+ def initialize(config)
8
+ @config = config
9
+ end
10
+
11
+ def list
12
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('orgs'))
13
+ end
14
+
15
+ def get(org_slug)
16
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path("orgs/#{enc(org_slug)}"))
17
+ end
18
+
19
+ def restore(org_id)
20
+ HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.org_path("orgs/#{enc(org_id)}/restore"))
21
+ end
22
+
23
+ def current
24
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('org'))
25
+ end
26
+
27
+ private
28
+
29
+ def enc(s)
30
+ ::URI.encode_www_form_component(s)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module K9o
4
+ # Policy bundle lifecycle namespace — k9o.policies.*.
5
+ class Policies
6
+ def initialize(config)
7
+ @config = config
8
+ end
9
+
10
+ def current
11
+ slug = require_project_slug
12
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, 'policy'))
13
+ end
14
+
15
+ def list(status: nil)
16
+ slug = require_project_slug
17
+ q = status ? { 'status' => status } : nil
18
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, 'policies'), query: q)
19
+ end
20
+
21
+ def create(name:, rules:)
22
+ slug = require_project_slug
23
+ HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.project_path(slug, 'policies'),
24
+ body: { 'name' => name, 'rules' => rules })
25
+ end
26
+
27
+ def validate(rules)
28
+ slug = require_project_slug
29
+ HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.project_path(slug, 'policies/validate'),
30
+ body: { 'rules' => rules })
31
+ end
32
+
33
+ def get(bundle_id)
34
+ slug = require_project_slug
35
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, "policies/#{enc(bundle_id)}"))
36
+ end
37
+
38
+ def update(bundle_id, patch)
39
+ slug = require_project_slug
40
+ HttpTransport.request(config: @config, method: 'PATCH',
41
+ path: UrlGrammar.project_path(slug, "policies/#{enc(bundle_id)}"), body: patch)
42
+ end
43
+
44
+ def delete(bundle_id)
45
+ slug = require_project_slug
46
+ HttpTransport.request(config: @config, method: 'DELETE', path: UrlGrammar.project_path(slug, "policies/#{enc(bundle_id)}"))
47
+ end
48
+
49
+ def publish(bundle_id)
50
+ slug = require_project_slug
51
+ HttpTransport.request(config: @config, method: 'POST',
52
+ path: UrlGrammar.project_path(slug, "policies/#{enc(bundle_id)}/publish"))
53
+ end
54
+
55
+ private
56
+
57
+ def require_project_slug
58
+ Studio.require_project_slug(@config, 'policies')
59
+ end
60
+
61
+ def enc(s)
62
+ ::URI.encode_www_form_component(s)
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module K9o
4
+ # Project lifecycle namespace — k9o.projects.* (Ruby snake_case).
5
+ class Projects
6
+ def initialize(config)
7
+ @config = config
8
+ end
9
+
10
+ def list
11
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('projects'))
12
+ end
13
+
14
+ def create(slug:, name:, custody_mode: nil)
15
+ UrlGrammar.validate_slug(slug, 'project')
16
+ body = { 'slug' => slug, 'name' => name }
17
+ body['custodyMode'] = custody_mode if custody_mode
18
+ HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.org_path('projects'), body: body)
19
+ end
20
+
21
+ def get(slug)
22
+ UrlGrammar.validate_slug(slug, 'project')
23
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, 'project'))
24
+ end
25
+
26
+ def update(slug, name: nil)
27
+ UrlGrammar.validate_slug(slug, 'project')
28
+ patch = {}
29
+ patch['name'] = name if name
30
+ HttpTransport.request(config: @config, method: 'PATCH', path: UrlGrammar.project_path(slug, 'project'), body: patch)
31
+ end
32
+ end
33
+ end