k9o 1.1.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.
data/lib/k9o/me.rb ADDED
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module K9o
4
+ # Operator-self namespace — k9o.me.*. Cross-org;
5
+ # org-plane routes under /me. Sub-namespaces: profile / sessions /
6
+ # api_tokens / notifications / audit_log / data_export.
7
+ class Me
8
+ attr_reader :profile, :sessions, :api_tokens, :notifications, :audit_log, :data_export
9
+
10
+ def initialize(config)
11
+ @profile = Profile.new(config)
12
+ @sessions = Sessions.new(config)
13
+ @api_tokens = ApiTokens.new(config)
14
+ @notifications = Notifications.new(config)
15
+ @audit_log = AuditLog.new(config)
16
+ @data_export = DataExport.new(config)
17
+ end
18
+
19
+ # k9o.me.profile.*
20
+ class Profile
21
+ def initialize(config)
22
+ @config = config
23
+ end
24
+
25
+ def get
26
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('me'))
27
+ end
28
+
29
+ def update(display_name: nil)
30
+ HttpTransport.request(config: @config, method: 'PATCH', path: UrlGrammar.org_path('me'),
31
+ body: { 'displayName' => display_name })
32
+ end
33
+ end
34
+
35
+ # k9o.me.sessions.*
36
+ class Sessions
37
+ def initialize(config)
38
+ @config = config
39
+ end
40
+
41
+ def list
42
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('me/sessions'))
43
+ end
44
+
45
+ def revoke(session_id)
46
+ HttpTransport.request(config: @config, method: 'DELETE',
47
+ path: UrlGrammar.org_path("me/sessions/#{enc(session_id)}"))
48
+ end
49
+
50
+ def revoke_all
51
+ HttpTransport.request(config: @config, method: 'DELETE', path: UrlGrammar.org_path('me/sessions'))
52
+ end
53
+
54
+ private
55
+
56
+ def enc(s)
57
+ ::URI.encode_www_form_component(s)
58
+ end
59
+ end
60
+
61
+ # k9o.me.api_tokens.*
62
+ class ApiTokens
63
+ def initialize(config)
64
+ @config = config
65
+ end
66
+
67
+ def list
68
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('me/api-tokens'))
69
+ end
70
+
71
+ def create(name:, expires_in_days: nil, scope: nil)
72
+ body = { 'name' => name }
73
+ body['expiresInDays'] = expires_in_days if expires_in_days
74
+ body['scope'] = scope if scope
75
+ HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.org_path('me/api-tokens'), body: body)
76
+ end
77
+
78
+ def delete(token_id)
79
+ HttpTransport.request(config: @config, method: 'DELETE',
80
+ path: UrlGrammar.org_path("me/api-tokens/#{enc(token_id)}"))
81
+ end
82
+
83
+ private
84
+
85
+ def enc(s)
86
+ ::URI.encode_www_form_component(s)
87
+ end
88
+ end
89
+
90
+ # k9o.me.notifications.*
91
+ class Notifications
92
+ def initialize(config)
93
+ @config = config
94
+ end
95
+
96
+ def list(unread_only: nil, limit: nil, cursor: nil)
97
+ q = {}
98
+ q['unreadOnly'] = unread_only unless unread_only.nil?
99
+ q['limit'] = limit if limit
100
+ q['cursor'] = cursor unless cursor.nil?
101
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('me/notifications'), query: q)
102
+ end
103
+
104
+ def mark_read(notification_id)
105
+ HttpTransport.request(config: @config, method: 'PATCH',
106
+ path: UrlGrammar.org_path("me/notifications/#{enc(notification_id)}"), body: { 'read' => true })
107
+ end
108
+
109
+ def mark_all_read
110
+ HttpTransport.request(config: @config, method: 'PATCH', path: UrlGrammar.org_path('me/notifications'),
111
+ body: { 'read' => true })
112
+ end
113
+
114
+ private
115
+
116
+ def enc(s)
117
+ ::URI.encode_www_form_component(s)
118
+ end
119
+ end
120
+
121
+ # k9o.me.audit_log.*
122
+ class AuditLog
123
+ def initialize(config)
124
+ @config = config
125
+ end
126
+
127
+ def query(since: nil, until_: nil, type: nil, limit: nil, cursor: nil)
128
+ q = {}
129
+ q['since'] = since if since
130
+ q['until'] = until_ if until_
131
+ q['type'] = type if type
132
+ q['limit'] = limit if limit
133
+ q['cursor'] = cursor if cursor
134
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('me/audit-log'), query: q)
135
+ end
136
+ end
137
+
138
+ # k9o.me.data_export.*
139
+ class DataExport
140
+ def initialize(config)
141
+ @config = config
142
+ end
143
+
144
+ def request(format: 'json')
145
+ HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.org_path('me/data-export'),
146
+ body: { 'format' => format })
147
+ end
148
+
149
+ def status(job_id)
150
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path("me/data-export/#{enc(job_id)}"))
151
+ end
152
+
153
+ private
154
+
155
+ def enc(s)
156
+ ::URI.encode_www_form_component(s)
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ module K9o
4
+ # Migration lifecycle namespace — k9o.migrations.*.
5
+ #
6
+ # TYPE MODEL (, 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.
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 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.* (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).
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