unitysvc-core 0.2.37__tar.gz → 0.3.1__tar.gz

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.
Files changed (54) hide show
  1. {unitysvc_core-0.2.37/src/unitysvc_core.egg-info → unitysvc_core-0.3.1}/PKG-INFO +67 -26
  2. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/README.md +66 -25
  3. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/pyproject.toml +1 -1
  4. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/src/unitysvc_core/grants.py +182 -5
  5. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/src/unitysvc_core/models/__init__.py +29 -0
  6. unitysvc_core-0.3.1/src/unitysvc_core/models/channel.py +302 -0
  7. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/src/unitysvc_core/models/listing_data.py +0 -11
  8. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/src/unitysvc_core/models/listing_v1.py +0 -1
  9. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/src/unitysvc_core/models/offering_v1.py +6 -23
  10. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/src/unitysvc_core/models/validators.py +100 -56
  11. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/src/unitysvc_core/parameter_usage.py +21 -46
  12. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/src/unitysvc_core/schema/listing_v1.json +0 -28
  13. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/src/unitysvc_core/schema/offering_v1.json +2479 -240
  14. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/src/unitysvc_core/validator.py +232 -174
  15. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1/src/unitysvc_core.egg-info}/PKG-INFO +67 -26
  16. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/src/unitysvc_core.egg-info/SOURCES.txt +3 -0
  17. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/tests/test_canonical_interfaces.py +31 -2
  18. unitysvc_core-0.3.1/tests/test_channel.py +138 -0
  19. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/tests/test_grants.py +235 -0
  20. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/tests/test_parameter_usage.py +51 -53
  21. unitysvc_core-0.3.1/tests/test_validate_service.py +115 -0
  22. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/tests/test_validator.py +83 -179
  23. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/LICENSE +0 -0
  24. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/setup.cfg +0 -0
  25. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/src/unitysvc_core/__init__.py +0 -0
  26. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/src/unitysvc_core/models/base.py +0 -0
  27. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/src/unitysvc_core/models/documents.py +0 -0
  28. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/src/unitysvc_core/models/offering_data.py +0 -0
  29. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/src/unitysvc_core/models/pricing.py +0 -0
  30. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/src/unitysvc_core/models/promotion_data.py +0 -0
  31. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/src/unitysvc_core/models/provider_data.py +0 -0
  32. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/src/unitysvc_core/models/provider_v1.py +0 -0
  33. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/src/unitysvc_core/models/service.py +0 -0
  34. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/src/unitysvc_core/models/service_group_data.py +0 -0
  35. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/src/unitysvc_core/py.typed +0 -0
  36. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/src/unitysvc_core/schema/base.json +0 -0
  37. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/src/unitysvc_core/schema/promotion_v1.json +0 -0
  38. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/src/unitysvc_core/schema/provider_v1.json +0 -0
  39. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/src/unitysvc_core/schema/service_group_v1.json +0 -0
  40. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/src/unitysvc_core/utils.py +0 -0
  41. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/src/unitysvc_core.egg-info/dependency_links.txt +0 -0
  42. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/src/unitysvc_core.egg-info/requires.txt +0 -0
  43. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/src/unitysvc_core.egg-info/top_level.txt +0 -0
  44. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/tests/test_access_interface_routing.py +0 -0
  45. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/tests/test_effective_price.py +0 -0
  46. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/tests/test_hidden_path_skip.py +0 -0
  47. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/tests/test_listing_audience.py +0 -0
  48. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/tests/test_offering_data.py +0 -0
  49. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/tests/test_pricing.py +0 -0
  50. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/tests/test_provider_account_rate_limits.py +0 -0
  51. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/tests/test_service_group.py +0 -0
  52. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/tests/test_service_status.py +0 -0
  53. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/tests/test_utils.py +0 -0
  54. {unitysvc_core-0.2.37 → unitysvc_core-0.3.1}/tests/test_validators.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unitysvc-core
3
- Version: 0.2.37
3
+ Version: 0.3.1
4
4
  Summary: Shared data models and validation helpers for UnitySVC packages
5
5
  Author-email: Bo Peng <bo.peng@unitysvc.com>
6
6
  Maintainer-email: Bo Peng <bo.peng@unitysvc.com>
@@ -57,6 +57,8 @@ src/unitysvc_core/
57
57
  │ ├── base.py # All enums (status, type, currency, rate-limit, etc.)
58
58
  │ ├── pricing.py # PriceStr, UsageData, all *PriceData classes,
59
59
  │ │ # Pricing union, validate_pricing, cost calculation
60
+ │ ├── channel.py # UpstreamAccessConfig, ChannelConfig, per-protocol
61
+ │ │ # servers, ChannelParams, RETIRED_CHANNEL_KEYS
60
62
  │ ├── documents.py # DocumentData
61
63
  │ ├── service.py # RateLimit, ServiceConstraints, AccessInterfaceData,
62
64
  │ │ # UpstreamAccessConfigData
@@ -135,24 +137,26 @@ MIT
135
137
 
136
138
  ### Required enrollment parameter usage
137
139
 
138
- The shared validator rejects required user parameters that no channel, interface,
139
- or document consumes. Only those consumer fields count — the offering's
140
- `upstream_access_config`, the listing's `user_access_interfaces`, and document
141
- content (a `.j2` document's template source, which the seller validator reads from
142
- disk as the SDK uploads it); metadata such as `display_name` or `description` is
143
- never rendered with `params`. File validation checks a listing against its paired offering
144
- when present; backend ingest calls the same `validate_required_parameter_usage`
145
- helper with the complete listing and offering. Schema declarations alone are not
146
- consumers. Jinja attribute access, string-key lookups, and `params.get("name")`
147
- are inspected; dynamic access and whole-map iteration conservatively consume all
148
- parameters. Deprecated content can still be retired.
149
-
150
- This validates usage, not channel attribution: a document reference counts as a
151
- consumer without proving that the intended paid channel takes enrollment.
140
+ Each channel's required parameters must be read by that channel or by document
141
+ content (a `.j2` document's template source, which the seller validator reads
142
+ from disk as the SDK uploads it); another channel's use does not count. Metadata
143
+ such as `display_name` or `description` is never rendered with `params`, and a
144
+ user access interface never reads them. File validation checks a listing against
145
+ its paired offering when present; backend ingest calls the same
146
+ `validate_required_parameter_usage` helper with the complete listing and
147
+ offering. Schema declarations alone are not consumers. Jinja attribute access,
148
+ string-key lookups, and `params.get("name")` are inspected; dynamic access and
149
+ whole-map iteration conservatively consume all parameters. Deprecated content can
150
+ still be retired.
152
151
 
153
152
  ### Canonical user access interfaces
154
153
 
155
- A user access interface is the service's canonical address, the same for every caller, so `validate_listing_interfaces_are_canonical` refuses one that reads a per-enrollment value (`enrollment.*`, `params.*`). An enrollment is reached through the address the platform gives it: `/e/<CODE>` on the API gateway, which a listing never declares. The only per-enrollment form allowed is where the protocol has no such path and the code itself is the address: an SMTP `routing_key.username` or an S3 bucket segment that is exactly `{{ enrollment.code }}`. Values the same for every caller (`service_name`, `routing_vars`) stay allowed. The shared validator runs it on every `listing_v1`; backend ingest calls the same function (unitysvc/unitysvc#2726).
154
+ A user access interface is the service's canonical address, the same for every caller. `validate_listing_interfaces_are_canonical` renders `{{ service_name }}` (the one value the platform fills in when it stores an interface) and then requires:
155
+
156
+ - a `base_url` that is one of the four gateways, `${API_GATEWAY_BASE_URL}`, `${S3_GATEWAY_BASE_URL}`, `${SMTP_GATEWAY_BASE_URL}` or `${MCP_GATEWAY_BASE_URL}`, optionally followed by `/` and a path (`/a/<alias>` included); never an external URL, another variable or a secret reference;
157
+ - no template left over. `{{ routing_vars.X }}` would stay literal in the stored interface and never route, and a per-enrollment value (`enrollment.*`, `params.*`) is never canonical: an enrollment is reached through `/e/<CODE>` on the API gateway, which a listing never declares.
158
+
159
+ The one exception is `{{ enrollment.code }}` as the whole SMTP `routing_key.username` or the whole S3 bucket segment, a reminder of the per-enrollment address on protocols without a path for it. The shared validator runs it on every `listing_v1`; backend ingest calls the same function (unitysvc/unitysvc#2726).
156
160
 
157
161
  ### Listing audience
158
162
 
@@ -193,7 +197,7 @@ Three stages (unitysvc/unitysvc#2720): a target service's **`exports`** (what it
193
197
  - **Without `permissions`** (most services, including one that declares only `options`), the service exports one permission, `invoke`, on every route (`"* /*"`); `service:<name>` and the `service:*` wildcard grant it.
194
198
  - **With `permissions`**, the service exports exactly those, and `invoke` only if it declares it too. A data service (`me`, `log`, `needs`) declares `read` / `write` and no `invoke`: it holds the customer's own data, so only a grant that names its permissions reaches it, never `invoke` or a wildcard.
195
199
  - `permissions`: a name (lowercase letters, digits and `_`) → the routes it covers, each `"<METHOD> /<path>"` (`*` for any method, `*` globs in the path). Permissions are never inferred from HTTP methods.
196
- - `options`: a JSON Schema (draft 7) object of the customizations a grant may set on the service, like an enrollment's `user_parameters_schema`, with an optional react-jsonschema-form `options_ui_schema`; the frontend renders it with the same form component. It covers limits only the service can apply (a message size). Restrictions so a grant can write every value in query-string syntax: property names are lowercase letters, digits and `_`; each property is a `string`, `integer`, `number`, `boolean` or an `array` of strings, serialized as an OpenAPI form-style query parameter with arrays comma-joined (`style: form, explode: false`); options are optional (no `required`) and only declared properties are accepted; the schema is self-contained (no `$ref`), so validating against it never fetches anything. A property's `default` is what the service does when the policy does not set the option.
200
+ - `options`: a JSON Schema (draft 7) object of the customizations a grant may set on the service, like a channel's `params.schema`, with an optional react-jsonschema-form `options_ui_schema`; the frontend renders it with the same form component. It covers limits only the service can apply (a message size). Restrictions so a grant can write every value in query-string syntax: property names are lowercase letters, digits and `_`; each property is a `string`, `integer`, `number`, `boolean` or an `array` of strings, serialized as an OpenAPI form-style query parameter with arrays comma-joined (`style: form, explode: false`); options are optional (no `required`) and only declared properties are accepted; the schema is self-contained (no `$ref`), so validating against it never fetches anything. A property's `default` is what the service does when the policy does not set the option.
197
201
 
198
202
  **2. A customer access request (`upstream_access_config.<channel>.customer_access_requests`), and the policy entry it becomes.** Any channel may declare its own list; each approved request becomes an entry (a grant) in the issued key's policy, in the same shape:
199
203
 
@@ -204,10 +208,11 @@ Three stages (unitysvc/unitysvc#2720): a target service's **`exports`** (what it
204
208
  "limits": {"count_per_day": 1000}}]
205
209
  ```
206
210
 
207
- - `object` names one service (`service:<name>`, no wildcards). `enrollment:<CODE>` (one enrollment of an enrollable service) may be allowed later.
211
+ - `object` names one service (`service:<name>`, no wildcards), or `apikey` for the leased key's lifespan (below).
208
212
  - `options` is written in query-string syntax (`parse_options`; a notation, not how options are delivered): each name once, each value non-empty; with a catalog, each value is converted to its declared type and validated against the target's `options` schema.
209
213
  - **Enrollment is where options and limits are set.** In a request, `options` and `limits` only pre-fill the enrollment form for convenience. When enrolling, the customer confirms and sets the options (a form rendered from the target's `options` schema) and the limits, and adds their own limits; the result is the issued key's policy.
210
214
  - `limits` (`validate_limits`): `count_per_hour` / `count_per_day` / `count_per_month` / `count_total` (positive integer) and the same for `amount_` (finite positive decimal, as a number or a string). Limits are universal: the platform meters every call, so any service can be limited without exporting anything. The seller's limits are a pre-fill; the customer sets them at enrollment, and an enrollment's own limits (unitysvc/unitysvc#2687) use the same shape. They are counted per policy entry (the enrollment's calls to that service) from the usage stream (counters keyed by scope, metric and window bucket), and the gateway refuses calls once one is exceeded.
215
+ - **A longer-lived leased key** is asked for with one `apikey` entry: `{"object": "apikey", "limits": {"api_key_lifespan": 14400}, "justification": "Batch runs take up to four hours"}`. It names the key itself, so it takes only `limits` (exactly `api_key_lifespan`, whole seconds from 1 to 86400) and a `justification`, needs no catalog lookup, and grants nothing. Without it, a key the seller leases for the enrollment lives `DEFAULT_API_KEY_LIFESPAN` (3600 s). `api_key_lifespan` is valid only on this entry.
211
216
 
212
217
  **3. How options reach the service.** Not through the upstream URL: the gateway never changes a target's `base_url` or query to carry policy. How an option is applied is up to the service's implementation:
213
218
 
@@ -224,9 +229,48 @@ Three stages (unitysvc/unitysvc#2720): a target service's **`exports`** (what it
224
229
  - `DataValidator(data_dir, schema_dir, exports_resolver=...)` runs both checks on every `offering_v1` (`exports` and each channel's `customer_access_requests`). The seller SDK passes a resolver that downloads the catalog bundle once and reads the target row's `exports`; offline it passes none and gets the shape checks.
225
230
  - Backend ingest calls the same function with a resolver over the published services, so an unknown target, permission or option fails at upload.
226
231
 
232
+ **6. From approvals to a policy (`build_policy`).** When the customer enrolls, `build_policy(requests, approvals, resolve)` turns their approvals of the channel's requests into the policy the enrollment keeps and every key leased for it copies (unitysvc/unitysvc#2766):
233
+
234
+ ```json
235
+ [{"object": "service:log", "permissions": ["invoke"], "options": "log_level=error", "limits": {"count_per_day": 200}},
236
+ {"object": "apikey", "limits": {"api_key_lifespan": 7200}}]
237
+ ```
238
+
239
+ - Each approval names a requested object, at most once, with some of the permissions requested. Every `required` request must be approved; optional ones may be left out.
240
+ - Options and limits are the customer's. They are checked against the target's `exports` (with `resolve`) and `validate_limits`, not against the request, whose values only pre-fill the form.
241
+ - The `apikey` lifespan may be shortened, never lengthened beyond the request.
242
+ - The result drops `required` / `justification` and empty `options` / `limits`. `api_key_lifespan(policy)` reads the lifespan back (the default when there is none).
243
+
244
+ A policy has three kinds of entry (unitysvc/unitysvc#2770):
245
+
246
+ | Entry | Means |
247
+ | --- | --- |
248
+ | `{"object": "enrollment", "limits": {"amount_per_day": "1.00"}}` | This enrollment's own limits (total spending, say). The customer adds it without a request; it takes only `limits`, at least one, at most one entry. It grants nothing. |
249
+ | `{"object": "service:ai", ...}` | A service the app calls for the customer. A seller may name a specific one (`service:labs/llm-premium` instead of `service:ai` with `options="service=labs/llm-premium"`), at the cost of the customer's choice of provider, which a customer may decline. |
250
+ | `{"object": "service:<name>", "enrollment": "<CODE>", ...}` | One of the customer's enrollments of a service that requires enrolling (a chain enrollment), called through `/e/<CODE>`. The customer picks the enrollment when approving the request for `service:<name>`, so a request never carries a code. A canonical code, no `options` (the enrollment's params are fixed), permissions and limits as for any service entry. Core checks the code's form; the caller checks the enrollment is the customer's. |
251
+
252
+ ### Upstream access channels
253
+
254
+ `upstream_access_config` maps a channel name to a channel (`UpstreamAccessConfig`, `ChannelConfig`; unitysvc/unitysvc#2730). A channel describes one way the platform reaches the upstream, as a whole, and its `servers` hold the connection, typed by the channel's protocol:
255
+
256
+ | Channel key | What it is |
257
+ | --- | --- |
258
+ | `access_method` | `http` (default), `smtp`, `s3` or `mcp`; selects the server type |
259
+ | `type` | normally derived by the platform; declare `external` (or `enrollable`) to state it |
260
+ | `description`, `is_active`, `sort_order` | display text; inactive channels are skipped; rank, lowest first |
261
+ | `request_formats`, `applicable_interfaces` | the formats the channel serves; the interfaces that reach it (display only) |
262
+ | `rate_limit_refs` | the provider rate limits it consumes |
263
+ | `customer_access_requests` | what the enrolling customer is asked to approve (see above) |
264
+ | `params` | the enrollment parameters (below) |
265
+ | `servers` | the connection: `HttpServer`, `SmtpServer`, `S3Server` or `McpServer` |
266
+
267
+ The common one-server case writes the connection fields directly on the channel; they are read as `servers[0]`. When a channel has both, each server inherits the direct fields it does not set. Any string may be a template (`{{ params.X }}`, `${ secrets.X }`, `${ customer_secrets.X ?? default }`), so numeric and boolean connection fields also accept strings.
268
+
269
+ Unknown keys are refused. Retired keys are refused with what to do instead (`RETIRED_CHANNEL_KEYS`): `raw`, `is_primary`, the listing-style parameter trio, `grants`, `rate_limits` (nothing reads it; use `rate_limit_refs`), `storage_type` (use `access_method: s3`), `response_rules`, `timeout_ms`, `transport`, and the platform-derived `customer_secrets_required` / `customer_secrets_optional`.
270
+
227
271
  ### Enrollment parameters on channels
228
272
 
229
- A channel may declare its own enrollment parameters, so each plan of a service asks for what it needs (unitysvc/unitysvc#2726):
273
+ A channel declares the enrollment parameters it reads, so each plan of a service asks for what it needs (unitysvc/unitysvc#2726). A listing declares none: `user_parameters_schema`, `user_parameters_ui_schema` and `service_options.ops_testing_parameters` are refused.
230
274
 
231
275
  ```json
232
276
  "upstream_access_config": {
@@ -241,11 +285,8 @@ A channel may declare its own enrollment parameters, so each plan of a service a
241
285
  }
242
286
  ```
243
287
 
244
- `params.schema` is a JSON Schema of the parameters (a property's own `default` pre-fills the customer's form), `params.ui_schema` holds form hints, and `params.testing_defaults` holds the values the platform tests the channel with. Once any channel declares `params`:
245
-
246
- - a channel reads only the parameters its `params.schema` declares, and a channel that reads `{{ params.* }}` must declare its own `params` (`validate_channel_parameters`, on every `offering_v1`);
247
- - each required parameter has a value in that channel's `params.testing_defaults`;
248
- - each required parameter is read by its own channel or by document content; another channel's use does not count (`validate_required_parameter_usage`, which backend ingest also calls);
249
- - the listing must not also declare `user_parameters_schema`.
288
+ `params.schema` is a JSON Schema of the parameters (a property's own `default` pre-fills the customer's form), `params.ui_schema` holds form hints, and `params.testing_defaults` holds the values the platform tests the channel with. Rules:
250
289
 
251
- When no channel declares `params`, the listing-level `user_parameters_schema` applies to the whole service as before.
290
+ - a channel reads only the parameters its `params.schema` declares, and a channel that reads `{{ params.* }}` must declare `params` (`validate_channel_parameters`, on every `offering_v1`);
291
+ - each required parameter has a value in that channel's `params.testing_defaults`, which is also what `${ customer_secrets.{{ params.X }} }` renders with when secret references are checked;
292
+ - each required parameter is read by its own channel or by document content (`validate_required_parameter_usage`, which backend ingest also calls).
@@ -22,6 +22,8 @@ src/unitysvc_core/
22
22
  │ ├── base.py # All enums (status, type, currency, rate-limit, etc.)
23
23
  │ ├── pricing.py # PriceStr, UsageData, all *PriceData classes,
24
24
  │ │ # Pricing union, validate_pricing, cost calculation
25
+ │ ├── channel.py # UpstreamAccessConfig, ChannelConfig, per-protocol
26
+ │ │ # servers, ChannelParams, RETIRED_CHANNEL_KEYS
25
27
  │ ├── documents.py # DocumentData
26
28
  │ ├── service.py # RateLimit, ServiceConstraints, AccessInterfaceData,
27
29
  │ │ # UpstreamAccessConfigData
@@ -100,24 +102,26 @@ MIT
100
102
 
101
103
  ### Required enrollment parameter usage
102
104
 
103
- The shared validator rejects required user parameters that no channel, interface,
104
- or document consumes. Only those consumer fields count — the offering's
105
- `upstream_access_config`, the listing's `user_access_interfaces`, and document
106
- content (a `.j2` document's template source, which the seller validator reads from
107
- disk as the SDK uploads it); metadata such as `display_name` or `description` is
108
- never rendered with `params`. File validation checks a listing against its paired offering
109
- when present; backend ingest calls the same `validate_required_parameter_usage`
110
- helper with the complete listing and offering. Schema declarations alone are not
111
- consumers. Jinja attribute access, string-key lookups, and `params.get("name")`
112
- are inspected; dynamic access and whole-map iteration conservatively consume all
113
- parameters. Deprecated content can still be retired.
114
-
115
- This validates usage, not channel attribution: a document reference counts as a
116
- consumer without proving that the intended paid channel takes enrollment.
105
+ Each channel's required parameters must be read by that channel or by document
106
+ content (a `.j2` document's template source, which the seller validator reads
107
+ from disk as the SDK uploads it); another channel's use does not count. Metadata
108
+ such as `display_name` or `description` is never rendered with `params`, and a
109
+ user access interface never reads them. File validation checks a listing against
110
+ its paired offering when present; backend ingest calls the same
111
+ `validate_required_parameter_usage` helper with the complete listing and
112
+ offering. Schema declarations alone are not consumers. Jinja attribute access,
113
+ string-key lookups, and `params.get("name")` are inspected; dynamic access and
114
+ whole-map iteration conservatively consume all parameters. Deprecated content can
115
+ still be retired.
117
116
 
118
117
  ### Canonical user access interfaces
119
118
 
120
- A user access interface is the service's canonical address, the same for every caller, so `validate_listing_interfaces_are_canonical` refuses one that reads a per-enrollment value (`enrollment.*`, `params.*`). An enrollment is reached through the address the platform gives it: `/e/<CODE>` on the API gateway, which a listing never declares. The only per-enrollment form allowed is where the protocol has no such path and the code itself is the address: an SMTP `routing_key.username` or an S3 bucket segment that is exactly `{{ enrollment.code }}`. Values the same for every caller (`service_name`, `routing_vars`) stay allowed. The shared validator runs it on every `listing_v1`; backend ingest calls the same function (unitysvc/unitysvc#2726).
119
+ A user access interface is the service's canonical address, the same for every caller. `validate_listing_interfaces_are_canonical` renders `{{ service_name }}` (the one value the platform fills in when it stores an interface) and then requires:
120
+
121
+ - a `base_url` that is one of the four gateways, `${API_GATEWAY_BASE_URL}`, `${S3_GATEWAY_BASE_URL}`, `${SMTP_GATEWAY_BASE_URL}` or `${MCP_GATEWAY_BASE_URL}`, optionally followed by `/` and a path (`/a/<alias>` included); never an external URL, another variable or a secret reference;
122
+ - no template left over. `{{ routing_vars.X }}` would stay literal in the stored interface and never route, and a per-enrollment value (`enrollment.*`, `params.*`) is never canonical: an enrollment is reached through `/e/<CODE>` on the API gateway, which a listing never declares.
123
+
124
+ The one exception is `{{ enrollment.code }}` as the whole SMTP `routing_key.username` or the whole S3 bucket segment, a reminder of the per-enrollment address on protocols without a path for it. The shared validator runs it on every `listing_v1`; backend ingest calls the same function (unitysvc/unitysvc#2726).
121
125
 
122
126
  ### Listing audience
123
127
 
@@ -158,7 +162,7 @@ Three stages (unitysvc/unitysvc#2720): a target service's **`exports`** (what it
158
162
  - **Without `permissions`** (most services, including one that declares only `options`), the service exports one permission, `invoke`, on every route (`"* /*"`); `service:<name>` and the `service:*` wildcard grant it.
159
163
  - **With `permissions`**, the service exports exactly those, and `invoke` only if it declares it too. A data service (`me`, `log`, `needs`) declares `read` / `write` and no `invoke`: it holds the customer's own data, so only a grant that names its permissions reaches it, never `invoke` or a wildcard.
160
164
  - `permissions`: a name (lowercase letters, digits and `_`) → the routes it covers, each `"<METHOD> /<path>"` (`*` for any method, `*` globs in the path). Permissions are never inferred from HTTP methods.
161
- - `options`: a JSON Schema (draft 7) object of the customizations a grant may set on the service, like an enrollment's `user_parameters_schema`, with an optional react-jsonschema-form `options_ui_schema`; the frontend renders it with the same form component. It covers limits only the service can apply (a message size). Restrictions so a grant can write every value in query-string syntax: property names are lowercase letters, digits and `_`; each property is a `string`, `integer`, `number`, `boolean` or an `array` of strings, serialized as an OpenAPI form-style query parameter with arrays comma-joined (`style: form, explode: false`); options are optional (no `required`) and only declared properties are accepted; the schema is self-contained (no `$ref`), so validating against it never fetches anything. A property's `default` is what the service does when the policy does not set the option.
165
+ - `options`: a JSON Schema (draft 7) object of the customizations a grant may set on the service, like a channel's `params.schema`, with an optional react-jsonschema-form `options_ui_schema`; the frontend renders it with the same form component. It covers limits only the service can apply (a message size). Restrictions so a grant can write every value in query-string syntax: property names are lowercase letters, digits and `_`; each property is a `string`, `integer`, `number`, `boolean` or an `array` of strings, serialized as an OpenAPI form-style query parameter with arrays comma-joined (`style: form, explode: false`); options are optional (no `required`) and only declared properties are accepted; the schema is self-contained (no `$ref`), so validating against it never fetches anything. A property's `default` is what the service does when the policy does not set the option.
162
166
 
163
167
  **2. A customer access request (`upstream_access_config.<channel>.customer_access_requests`), and the policy entry it becomes.** Any channel may declare its own list; each approved request becomes an entry (a grant) in the issued key's policy, in the same shape:
164
168
 
@@ -169,10 +173,11 @@ Three stages (unitysvc/unitysvc#2720): a target service's **`exports`** (what it
169
173
  "limits": {"count_per_day": 1000}}]
170
174
  ```
171
175
 
172
- - `object` names one service (`service:<name>`, no wildcards). `enrollment:<CODE>` (one enrollment of an enrollable service) may be allowed later.
176
+ - `object` names one service (`service:<name>`, no wildcards), or `apikey` for the leased key's lifespan (below).
173
177
  - `options` is written in query-string syntax (`parse_options`; a notation, not how options are delivered): each name once, each value non-empty; with a catalog, each value is converted to its declared type and validated against the target's `options` schema.
174
178
  - **Enrollment is where options and limits are set.** In a request, `options` and `limits` only pre-fill the enrollment form for convenience. When enrolling, the customer confirms and sets the options (a form rendered from the target's `options` schema) and the limits, and adds their own limits; the result is the issued key's policy.
175
179
  - `limits` (`validate_limits`): `count_per_hour` / `count_per_day` / `count_per_month` / `count_total` (positive integer) and the same for `amount_` (finite positive decimal, as a number or a string). Limits are universal: the platform meters every call, so any service can be limited without exporting anything. The seller's limits are a pre-fill; the customer sets them at enrollment, and an enrollment's own limits (unitysvc/unitysvc#2687) use the same shape. They are counted per policy entry (the enrollment's calls to that service) from the usage stream (counters keyed by scope, metric and window bucket), and the gateway refuses calls once one is exceeded.
180
+ - **A longer-lived leased key** is asked for with one `apikey` entry: `{"object": "apikey", "limits": {"api_key_lifespan": 14400}, "justification": "Batch runs take up to four hours"}`. It names the key itself, so it takes only `limits` (exactly `api_key_lifespan`, whole seconds from 1 to 86400) and a `justification`, needs no catalog lookup, and grants nothing. Without it, a key the seller leases for the enrollment lives `DEFAULT_API_KEY_LIFESPAN` (3600 s). `api_key_lifespan` is valid only on this entry.
176
181
 
177
182
  **3. How options reach the service.** Not through the upstream URL: the gateway never changes a target's `base_url` or query to carry policy. How an option is applied is up to the service's implementation:
178
183
 
@@ -189,9 +194,48 @@ Three stages (unitysvc/unitysvc#2720): a target service's **`exports`** (what it
189
194
  - `DataValidator(data_dir, schema_dir, exports_resolver=...)` runs both checks on every `offering_v1` (`exports` and each channel's `customer_access_requests`). The seller SDK passes a resolver that downloads the catalog bundle once and reads the target row's `exports`; offline it passes none and gets the shape checks.
190
195
  - Backend ingest calls the same function with a resolver over the published services, so an unknown target, permission or option fails at upload.
191
196
 
197
+ **6. From approvals to a policy (`build_policy`).** When the customer enrolls, `build_policy(requests, approvals, resolve)` turns their approvals of the channel's requests into the policy the enrollment keeps and every key leased for it copies (unitysvc/unitysvc#2766):
198
+
199
+ ```json
200
+ [{"object": "service:log", "permissions": ["invoke"], "options": "log_level=error", "limits": {"count_per_day": 200}},
201
+ {"object": "apikey", "limits": {"api_key_lifespan": 7200}}]
202
+ ```
203
+
204
+ - Each approval names a requested object, at most once, with some of the permissions requested. Every `required` request must be approved; optional ones may be left out.
205
+ - Options and limits are the customer's. They are checked against the target's `exports` (with `resolve`) and `validate_limits`, not against the request, whose values only pre-fill the form.
206
+ - The `apikey` lifespan may be shortened, never lengthened beyond the request.
207
+ - The result drops `required` / `justification` and empty `options` / `limits`. `api_key_lifespan(policy)` reads the lifespan back (the default when there is none).
208
+
209
+ A policy has three kinds of entry (unitysvc/unitysvc#2770):
210
+
211
+ | Entry | Means |
212
+ | --- | --- |
213
+ | `{"object": "enrollment", "limits": {"amount_per_day": "1.00"}}` | This enrollment's own limits (total spending, say). The customer adds it without a request; it takes only `limits`, at least one, at most one entry. It grants nothing. |
214
+ | `{"object": "service:ai", ...}` | A service the app calls for the customer. A seller may name a specific one (`service:labs/llm-premium` instead of `service:ai` with `options="service=labs/llm-premium"`), at the cost of the customer's choice of provider, which a customer may decline. |
215
+ | `{"object": "service:<name>", "enrollment": "<CODE>", ...}` | One of the customer's enrollments of a service that requires enrolling (a chain enrollment), called through `/e/<CODE>`. The customer picks the enrollment when approving the request for `service:<name>`, so a request never carries a code. A canonical code, no `options` (the enrollment's params are fixed), permissions and limits as for any service entry. Core checks the code's form; the caller checks the enrollment is the customer's. |
216
+
217
+ ### Upstream access channels
218
+
219
+ `upstream_access_config` maps a channel name to a channel (`UpstreamAccessConfig`, `ChannelConfig`; unitysvc/unitysvc#2730). A channel describes one way the platform reaches the upstream, as a whole, and its `servers` hold the connection, typed by the channel's protocol:
220
+
221
+ | Channel key | What it is |
222
+ | --- | --- |
223
+ | `access_method` | `http` (default), `smtp`, `s3` or `mcp`; selects the server type |
224
+ | `type` | normally derived by the platform; declare `external` (or `enrollable`) to state it |
225
+ | `description`, `is_active`, `sort_order` | display text; inactive channels are skipped; rank, lowest first |
226
+ | `request_formats`, `applicable_interfaces` | the formats the channel serves; the interfaces that reach it (display only) |
227
+ | `rate_limit_refs` | the provider rate limits it consumes |
228
+ | `customer_access_requests` | what the enrolling customer is asked to approve (see above) |
229
+ | `params` | the enrollment parameters (below) |
230
+ | `servers` | the connection: `HttpServer`, `SmtpServer`, `S3Server` or `McpServer` |
231
+
232
+ The common one-server case writes the connection fields directly on the channel; they are read as `servers[0]`. When a channel has both, each server inherits the direct fields it does not set. Any string may be a template (`{{ params.X }}`, `${ secrets.X }`, `${ customer_secrets.X ?? default }`), so numeric and boolean connection fields also accept strings.
233
+
234
+ Unknown keys are refused. Retired keys are refused with what to do instead (`RETIRED_CHANNEL_KEYS`): `raw`, `is_primary`, the listing-style parameter trio, `grants`, `rate_limits` (nothing reads it; use `rate_limit_refs`), `storage_type` (use `access_method: s3`), `response_rules`, `timeout_ms`, `transport`, and the platform-derived `customer_secrets_required` / `customer_secrets_optional`.
235
+
192
236
  ### Enrollment parameters on channels
193
237
 
194
- A channel may declare its own enrollment parameters, so each plan of a service asks for what it needs (unitysvc/unitysvc#2726):
238
+ A channel declares the enrollment parameters it reads, so each plan of a service asks for what it needs (unitysvc/unitysvc#2726). A listing declares none: `user_parameters_schema`, `user_parameters_ui_schema` and `service_options.ops_testing_parameters` are refused.
195
239
 
196
240
  ```json
197
241
  "upstream_access_config": {
@@ -206,11 +250,8 @@ A channel may declare its own enrollment parameters, so each plan of a service a
206
250
  }
207
251
  ```
208
252
 
209
- `params.schema` is a JSON Schema of the parameters (a property's own `default` pre-fills the customer's form), `params.ui_schema` holds form hints, and `params.testing_defaults` holds the values the platform tests the channel with. Once any channel declares `params`:
210
-
211
- - a channel reads only the parameters its `params.schema` declares, and a channel that reads `{{ params.* }}` must declare its own `params` (`validate_channel_parameters`, on every `offering_v1`);
212
- - each required parameter has a value in that channel's `params.testing_defaults`;
213
- - each required parameter is read by its own channel or by document content; another channel's use does not count (`validate_required_parameter_usage`, which backend ingest also calls);
214
- - the listing must not also declare `user_parameters_schema`.
253
+ `params.schema` is a JSON Schema of the parameters (a property's own `default` pre-fills the customer's form), `params.ui_schema` holds form hints, and `params.testing_defaults` holds the values the platform tests the channel with. Rules:
215
254
 
216
- When no channel declares `params`, the listing-level `user_parameters_schema` applies to the whole service as before.
255
+ - a channel reads only the parameters its `params.schema` declares, and a channel that reads `{{ params.* }}` must declare `params` (`validate_channel_parameters`, on every `offering_v1`);
256
+ - each required parameter has a value in that channel's `params.testing_defaults`, which is also what `${ customer_secrets.{{ params.X }} }` renders with when secret references are checked;
257
+ - each required parameter is read by its own channel or by document content (`validate_required_parameter_usage`, which backend ingest also calls).
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "unitysvc-core"
7
- version = "0.2.37"
7
+ version = "0.3.1"
8
8
  description = "Shared data models and validation helpers for UnitySVC packages"
9
9
  readme = "README.md"
10
10
  authors = [{ name = "Bo Peng", email = "bo.peng@unitysvc.com" }]
@@ -22,7 +22,7 @@ Three stages (unitysvc/unitysvc#2720):
22
22
  that names its permissions, never by ``invoke`` or a wildcard. Permissions
23
23
  name the routes they cover; they are never inferred from HTTP methods
24
24
  (``POST /chat/completions`` modifies nothing). ``options`` is a JSON Schema,
25
- like an enrollment's ``user_parameters_schema`` (with an optional
25
+ like a channel's ``params.schema`` (with an optional
26
26
  ``options_ui_schema``), of the customizations a grant may set on the
27
27
  service, including limits only it can apply (a message size); a property's
28
28
  ``default`` is what the service does when the policy does not set the
@@ -72,8 +72,27 @@ customer consent and key issuance establish its *authority*.
72
72
  be limited by ``count`` or ``amount`` per hour, day or month, or in total,
73
73
  without exporting anything (``{"count_per_day": 2, "amount_per_month": "2.00"}``).
74
74
 
75
- Today a request's ``object`` is ``service:<name>``; ``enrollment:<CODE>`` (one
76
- enrollment of an enrollable service) may follow.
75
+ Today a request's ``object`` is ``service:<name>``, or ``apikey``: how long a
76
+ key leased for the enrollment lives (``limits.api_key_lifespan``, up to a day,
77
+ with a justification), which grants nothing. :func:`build_policy` turns a
78
+ customer's approvals into the policy an enrollment keeps.
79
+
80
+ A policy has three kinds of entry (unitysvc/unitysvc#2770):
81
+
82
+ * ``{"object": "enrollment", "limits": {...}}``: this enrollment's own limits
83
+ (what it may cost in total, say). The customer's own; no seller requests it,
84
+ and it grants nothing.
85
+ * ``{"object": "service:<name>", ...}``: a service the enrolled app calls for
86
+ the customer, usually a common one (``service:ai``, ``service:notify``). A
87
+ seller may name a specific service (``service:labs/llm-premium`` rather than
88
+ ``service:ai`` with ``options="service=labs/llm-premium"``), at the cost of
89
+ the customer's choice of provider, which a customer may decline.
90
+ * ``{"object": "service:<name>", "enrollment": "<CODE>", ...}``: one of the
91
+ customer's enrollments of a service that requires enrolling (a chain
92
+ enrollment), which the app calls through ``/e/<CODE>``. Its params are fixed
93
+ by that enrollment, so it takes no ``options``. Which enrollment is the
94
+ customer's choice when approving, so a request never names one; core checks
95
+ the code's form, the caller that the enrollment is the customer's.
77
96
 
78
97
  There is no central list of services. Core does no I/O: every request's
79
98
  permissions and options are checked against its target's ``exports`` when the
@@ -101,6 +120,13 @@ from .models.validators import validate_service_identifier
101
120
 
102
121
  SERVICE_PREFIX = "service:"
103
122
  INVOKE = "invoke"
123
+ # The key itself: a request for a longer-lived leased key, and the approved
124
+ # lifespan in a policy. It grants nothing.
125
+ APIKEY_OBJECT = "apikey"
126
+ API_KEY_LIFESPAN = "api_key_lifespan"
127
+ # Seconds a leased key lives unless the policy says otherwise, and at most.
128
+ DEFAULT_API_KEY_LIFESPAN = 3600
129
+ MAX_API_KEY_LIFESPAN = 86400
104
130
  # The channel key a seller's requests are declared under.
105
131
  REQUESTS_KEY = "customer_access_requests"
106
132
 
@@ -144,6 +170,15 @@ DEFAULT_PERMISSIONS: dict[str, tuple[str, ...]] = {INVOKE: (ALL_ROUTES,)}
144
170
  DEFAULT_EXPORTS = Exports(DEFAULT_PERMISSIONS, {})
145
171
 
146
172
  _REQUEST_KEYS = frozenset({"object", "permissions", "required", "justification", "options", "limits"})
173
+ _APIKEY_REQUEST_KEYS = frozenset({"object", "justification", "limits"})
174
+ # What a policy entry (an approved request) holds.
175
+ _POLICY_KEYS = frozenset({"object", "permissions", "options", "limits"})
176
+ # This enrollment itself: an entry capping what it may cost. It grants nothing.
177
+ ENROLLMENT_OBJECT = "enrollment"
178
+ # On a service entry, one enrollment of that service (a chain enrollment).
179
+ ENROLLMENT_KEY = "enrollment"
180
+ # An enrollment code: canonical Crockford base32 (no I, L, O, U).
181
+ _ENROLLMENT_CODE = re.compile(r"^[0-9A-HJKMNP-TV-Z]+$")
147
182
 
148
183
 
149
184
  def _strings(value: Any) -> bool:
@@ -310,15 +345,29 @@ def parse_options(where: str, options: Any) -> dict[str, str]:
310
345
  return parsed
311
346
 
312
347
 
313
- def validate_limits(where: str, limits: Any) -> None:
348
+ def validate_limits(where: str, limits: Any, *, apikey: bool = False) -> None:
314
349
  """Refuse ``limits`` unless it maps ``<metric>_per_<window>`` or
315
350
  ``<metric>_total`` to a maximum: ``count`` a positive integer, ``amount`` a
316
351
  finite positive decimal, as a number (``2.5``) or a string (``"2.50"``).
317
352
  Windows: hour, day, month. The same shape serves a request's suggested
318
353
  limits, the limits a customer sets in a key's policy, and an enrollment's own
319
- limits."""
354
+ limits.
355
+
356
+ With ``apikey`` (the ``apikey`` entry), the only limit is
357
+ ``api_key_lifespan``: how many seconds a leased key lives, an integer from 1
358
+ to :data:`MAX_API_KEY_LIFESPAN`.
359
+ """
320
360
  if not isinstance(limits, dict):
321
361
  raise ValueError(f'{where} must be an object like {{"count_per_day": 2}}')
362
+ if apikey:
363
+ if set(limits) != {API_KEY_LIFESPAN}:
364
+ raise ValueError(f'{where} must be {{"{API_KEY_LIFESPAN}": <seconds>}}')
365
+ value = limits[API_KEY_LIFESPAN]
366
+ if not isinstance(value, int) or isinstance(value, bool) or not 0 < value <= MAX_API_KEY_LIFESPAN:
367
+ raise ValueError(
368
+ f"{where}.{API_KEY_LIFESPAN} must be a whole number of seconds from 1 to {MAX_API_KEY_LIFESPAN}"
369
+ )
370
+ return
322
371
  for key, value in limits.items():
323
372
  at = f"{where}.{key}"
324
373
  match = _LIMIT.match(key) if isinstance(key, str) else None
@@ -351,6 +400,12 @@ def validate_access_requests(where: str, requests: Any, resolve: ExportsResolver
351
400
  if not isinstance(entry, dict) or not isinstance(entry.get("object"), str):
352
401
  raise ValueError(f'{at} must be {{"object": "service:<name>", ...}}')
353
402
  obj = entry["object"]
403
+ if obj == APIKEY_OBJECT:
404
+ if obj in seen:
405
+ raise ValueError(f"{at}: {obj} is listed twice")
406
+ seen.add(obj)
407
+ _check_apikey_request(at, entry)
408
+ continue
354
409
  if not obj.startswith(SERVICE_PREFIX):
355
410
  raise ValueError(f"{at}: {obj!r} cannot be requested; name one service as 'service:<name>'")
356
411
  name = obj.removeprefix(SERVICE_PREFIX)
@@ -383,6 +438,16 @@ def validate_access_requests(where: str, requests: Any, resolve: ExportsResolver
383
438
  check_claim(at, obj, permissions, options, parse_exports(f"{obj} exports", declared))
384
439
 
385
440
 
441
+ def _check_apikey_request(at: str, entry: dict[str, Any]) -> None:
442
+ """The ``apikey`` request: how long a leased key should live, with why."""
443
+ if extra := set(entry) - _APIKEY_REQUEST_KEYS:
444
+ raise ValueError(f"{at}: unknown keys {sorted(extra)} (the {APIKEY_OBJECT} request takes only limits)")
445
+ justification = entry.get("justification")
446
+ if not isinstance(justification, str) or not justification.strip():
447
+ raise ValueError(f"{at}.justification must say why a leased key needs to live this long; the customer reads it")
448
+ validate_limits(f"{at}.limits", entry.get("limits"), apikey=True)
449
+
450
+
386
451
  def check_claim(where: str, obj: str, permissions: list[str], options: dict[str, str], exports: Exports) -> None:
387
452
  """Refuse a claim on ``obj`` (``service:<name>``) that its ``exports`` don't cover.
388
453
 
@@ -410,3 +475,115 @@ def validate_channel_access_requests(config: Any, resolve: ExportsResolver | Non
410
475
  for name, channel in config.items():
411
476
  if isinstance(channel, dict) and REQUESTS_KEY in channel:
412
477
  validate_access_requests(f"upstream_access_config.{name}.{REQUESTS_KEY}", channel[REQUESTS_KEY], resolve)
478
+
479
+
480
+ def build_policy(requests: Any, approvals: Any, resolve: ExportsResolver | None = None) -> list[dict[str, Any]]:
481
+ """The key policy a customer's ``approvals`` of a channel's ``requests``
482
+ make (unitysvc/unitysvc#2766).
483
+
484
+ ``requests`` is the channel's ``customer_access_requests``; ``approvals``
485
+ lists the requests the customer approves, each ``{"object", "permissions",
486
+ "options", "limits"}`` (the ``apikey`` entry only ``{"object", "limits"}``).
487
+ Each approval names a requested object, at most once; its permissions are
488
+ some of those requested; every ``required`` request is approved. The
489
+ customer sets options and limits: a request's own only pre-fill them, so
490
+ they are checked against the target's ``exports`` (with ``resolve``) and
491
+ :func:`validate_limits`, not against the request. A key lifespan may only
492
+ be shortened. Returns the approvals as policy entries, without empty
493
+ ``options`` / ``limits``. Raises ``ValueError``.
494
+
495
+ The customer may also add, at most once and without a request, this
496
+ enrollment's own limits: ``{"object": "enrollment", "limits": {...}}``,
497
+ with at least one limit and nothing else (unitysvc/unitysvc#2770). And an
498
+ approval of a service may select one of the customer's enrollments of it
499
+ (``"enrollment": "<CODE>"``, a chain enrollment): a canonical code, and no
500
+ ``options``. The caller checks the enrollment is the customer's.
501
+ """
502
+ validate_access_requests("requests", requests)
503
+ requested = {entry["object"]: entry for entry in requests}
504
+ if not isinstance(approvals, list):
505
+ raise ValueError("approvals must be a list of approved requests")
506
+ policy: list[dict[str, Any]] = []
507
+ for index, approval in enumerate(approvals):
508
+ at = f"approvals[{index}]"
509
+ if not isinstance(approval, dict) or not isinstance(approval.get("object"), str):
510
+ raise ValueError(f'{at} must be {{"object": ..., "permissions": [...]}}')
511
+ obj = approval["object"]
512
+ if obj == ENROLLMENT_OBJECT:
513
+ if any(entry["object"] == obj for entry in policy):
514
+ raise ValueError(f"{at}: {obj} is listed twice")
515
+ policy.append(_enrollment_entry(at, approval))
516
+ continue
517
+ request = requested.get(obj)
518
+ if request is None:
519
+ raise ValueError(f"{at}: {obj} is not requested")
520
+ if any(entry["object"] == obj for entry in policy):
521
+ raise ValueError(f"{at}: {obj} is approved twice")
522
+ if obj == APIKEY_OBJECT:
523
+ if extra := set(approval) - {"object", "limits"}:
524
+ raise ValueError(f"{at}: unknown keys {sorted(extra)} (the {APIKEY_OBJECT} entry takes only limits)")
525
+ validate_limits(f"{at}.limits", approval.get("limits"), apikey=True)
526
+ asked = request["limits"][API_KEY_LIFESPAN]
527
+ if approval["limits"][API_KEY_LIFESPAN] > asked:
528
+ raise ValueError(f"{at}.limits.{API_KEY_LIFESPAN}: the service asks for at most {asked} seconds")
529
+ policy.append({"object": obj, "limits": dict(approval["limits"])})
530
+ continue
531
+ if extra := set(approval) - _POLICY_KEYS - {ENROLLMENT_KEY}:
532
+ raise ValueError(f"{at}: unknown keys {sorted(extra)}")
533
+ code = approval.get(ENROLLMENT_KEY)
534
+ if ENROLLMENT_KEY in approval:
535
+ if not isinstance(code, str) or not _ENROLLMENT_CODE.match(code):
536
+ raise ValueError(f"{at}.{ENROLLMENT_KEY} must be an enrollment code, like JYY87Y")
537
+ if approval.get("options"):
538
+ raise ValueError(f"{at}.options: an enrollment's params are fixed; it takes no options")
539
+ permissions: list[str] = approval.get("permissions") or []
540
+ if not _strings(permissions):
541
+ raise ValueError(f"{at}.permissions must be a non-empty list of distinct names")
542
+ if not set(permissions) <= set(request["permissions"]):
543
+ raise ValueError(f"{at}.permissions: the service requests only {request['permissions']}")
544
+ options_text = approval.get("options", "")
545
+ options = parse_options(f"{at}.options", options_text)
546
+ limits = approval.get("limits", {})
547
+ validate_limits(f"{at}.limits", limits)
548
+ if resolve is not None:
549
+ declared = resolve(obj.removeprefix(SERVICE_PREFIX))
550
+ if declared is None:
551
+ raise ValueError(f"{at}: {obj} is not a published service")
552
+ check_claim(at, obj, permissions, options, parse_exports(f"{obj} exports", declared))
553
+ entry: dict[str, Any] = {"object": obj, "permissions": list(permissions)}
554
+ if code is not None:
555
+ entry[ENROLLMENT_KEY] = code
556
+ if options:
557
+ entry["options"] = options_text
558
+ if limits:
559
+ entry["limits"] = dict(limits)
560
+ policy.append(entry)
561
+ approved = {entry["object"] for entry in policy}
562
+ if missing := sorted(obj for obj, request in requested.items() if request.get("required") and obj not in approved):
563
+ raise ValueError(f"approvals: the service requires {', '.join(missing)}")
564
+ return policy
565
+
566
+
567
+ def _enrollment_entry(at: str, approval: dict[str, Any]) -> dict[str, Any]:
568
+ """This enrollment's own entry: limits only, and at least one."""
569
+ if extra := set(approval) - {"object", "limits"}:
570
+ raise ValueError(f"{at}: unknown keys {sorted(extra)} (the {ENROLLMENT_OBJECT} entry takes only limits)")
571
+ limits = approval.get("limits")
572
+ if not limits:
573
+ raise ValueError(f"{at}.limits: the {ENROLLMENT_OBJECT} entry must set at least one limit")
574
+ validate_limits(f"{at}.limits", limits)
575
+ return {"object": ENROLLMENT_OBJECT, "limits": dict(limits)}
576
+
577
+
578
+ def api_key_lifespan(policy: Any) -> int:
579
+ """Seconds a key leased with ``policy`` lives: its ``apikey`` entry's
580
+ ``api_key_lifespan``, else :data:`DEFAULT_API_KEY_LIFESPAN`. A malformed
581
+ entry gets the default, never more."""
582
+ for entry in policy if isinstance(policy, list) else []:
583
+ if isinstance(entry, dict) and entry.get("object") == APIKEY_OBJECT:
584
+ try:
585
+ validate_limits("apikey.limits", entry.get("limits"), apikey=True)
586
+ except ValueError:
587
+ return DEFAULT_API_KEY_LIFESPAN
588
+ return int(entry["limits"][API_KEY_LIFESPAN])
589
+ return DEFAULT_API_KEY_LIFESPAN
@@ -26,6 +26,21 @@ from .base import (
26
26
  TimeWindow,
27
27
  UpstreamStatusEnum, # Backwards compatibility alias for OfferingStatusEnum
28
28
  )
29
+ from .channel import (
30
+ RETIRED_CHANNEL_KEYS,
31
+ ChannelConfig,
32
+ ChannelParams,
33
+ HttpChannel,
34
+ HttpServer,
35
+ McpChannel,
36
+ McpServer,
37
+ RequestTransformer,
38
+ S3Channel,
39
+ S3Server,
40
+ SmtpChannel,
41
+ SmtpServer,
42
+ UpstreamAccessConfig,
43
+ )
29
44
  from .documents import DocumentData
30
45
  from .listing_data import ServiceListingData
31
46
  from .listing_v1 import ListingV1
@@ -104,6 +119,20 @@ __all__ = [
104
119
  "ListingV1",
105
120
  "PromotionV1",
106
121
  "ServiceGroupV1",
122
+ # Upstream access channels
123
+ "UpstreamAccessConfig",
124
+ "ChannelConfig",
125
+ "ChannelParams",
126
+ "HttpChannel",
127
+ "SmtpChannel",
128
+ "S3Channel",
129
+ "McpChannel",
130
+ "HttpServer",
131
+ "SmtpServer",
132
+ "S3Server",
133
+ "McpServer",
134
+ "RequestTransformer",
135
+ "RETIRED_CHANNEL_KEYS",
107
136
  # Data models (for API/backend use)
108
137
  "ProviderAccountRateLimit",
109
138
  "ProviderData",