unitysvc-core 0.2.36__tar.gz → 0.2.37__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 (52) hide show
  1. {unitysvc_core-0.2.36/src/unitysvc_core.egg-info → unitysvc_core-0.2.37}/PKG-INFO +39 -12
  2. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/README.md +38 -11
  3. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/pyproject.toml +1 -1
  4. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/grants.py +82 -45
  5. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/models/offering_data.py +4 -2
  6. unitysvc_core-0.2.37/src/unitysvc_core/parameter_usage.py +243 -0
  7. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/schema/offering_v1.json +1 -1
  8. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/validator.py +6 -1
  9. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37/src/unitysvc_core.egg-info}/PKG-INFO +39 -12
  10. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/tests/test_grants.py +60 -14
  11. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/tests/test_parameter_usage.py +132 -0
  12. unitysvc_core-0.2.36/src/unitysvc_core/parameter_usage.py +0 -120
  13. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/LICENSE +0 -0
  14. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/setup.cfg +0 -0
  15. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/__init__.py +0 -0
  16. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/models/__init__.py +0 -0
  17. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/models/base.py +0 -0
  18. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/models/documents.py +0 -0
  19. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/models/listing_data.py +0 -0
  20. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/models/listing_v1.py +0 -0
  21. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/models/offering_v1.py +0 -0
  22. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/models/pricing.py +0 -0
  23. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/models/promotion_data.py +0 -0
  24. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/models/provider_data.py +0 -0
  25. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/models/provider_v1.py +0 -0
  26. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/models/service.py +0 -0
  27. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/models/service_group_data.py +0 -0
  28. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/models/validators.py +0 -0
  29. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/py.typed +0 -0
  30. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/schema/base.json +0 -0
  31. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/schema/listing_v1.json +0 -0
  32. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/schema/promotion_v1.json +0 -0
  33. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/schema/provider_v1.json +0 -0
  34. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/schema/service_group_v1.json +0 -0
  35. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core/utils.py +0 -0
  36. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core.egg-info/SOURCES.txt +0 -0
  37. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core.egg-info/dependency_links.txt +0 -0
  38. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core.egg-info/requires.txt +0 -0
  39. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/src/unitysvc_core.egg-info/top_level.txt +0 -0
  40. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/tests/test_access_interface_routing.py +0 -0
  41. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/tests/test_canonical_interfaces.py +0 -0
  42. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/tests/test_effective_price.py +0 -0
  43. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/tests/test_hidden_path_skip.py +0 -0
  44. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/tests/test_listing_audience.py +0 -0
  45. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/tests/test_offering_data.py +0 -0
  46. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/tests/test_pricing.py +0 -0
  47. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/tests/test_provider_account_rate_limits.py +0 -0
  48. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/tests/test_service_group.py +0 -0
  49. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/tests/test_service_status.py +0 -0
  50. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/tests/test_utils.py +0 -0
  51. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/tests/test_validator.py +0 -0
  52. {unitysvc_core-0.2.36 → unitysvc_core-0.2.37}/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.36
3
+ Version: 0.2.37
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>
@@ -190,9 +190,10 @@ Three stages (unitysvc/unitysvc#2720): a target service's **`exports`** (what it
190
190
  }
191
191
  ```
192
192
 
193
- - `invoke` is always exported and covers every route; it may not be redeclared. A service that declares nothing (most of them) exports `invoke` only, with no options, which `service:<name>` or `service:*` covers.
194
- - `permissions`: a name → the routes it covers, each `"<METHOD> /<path>"` (`*` for any method, `*` globs in the path). Permissions are never inferred from HTTP methods.
195
- - `options`: a JSON Schema (draft 7) object of the query arguments the service accepts as customizations, 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 every value is one query argument: 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 option is not sent.
193
+ - **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
+ - **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
+ - `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.
196
197
 
197
198
  **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:
198
199
 
@@ -204,21 +205,47 @@ Three stages (unitysvc/unitysvc#2720): a target service's **`exports`** (what it
204
205
  ```
205
206
 
206
207
  - `object` names one service (`service:<name>`, no wildcards). `enrollment:<CODE>` (one enrollment of an enrollable service) may be allowed later.
207
- - `options` is a query string (`parse_options`): 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.
208
+ - `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.
208
209
  - **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.
209
210
  - `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.
210
211
 
211
- **3. How options reach the service.** No new channel: the upstream URL. When the gateway constructs the target's upstream URL for a call made under a policy entry, it appends that entry's `options` as query arguments, `?log_level=warn&max_size=2000`, the way query arguments already reach every upstream, `log://` included. Rules:
212
+ **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:
212
213
 
213
- - The gateway first strips any query argument the client sent under one of the target's exported option names, then appends the policy's values. Today the client's arguments win on a name collision, so without this a caller could override its own policy.
214
- - An option the policy does not set is not sent; the service applies its own default. A plain `service:*` key sends none.
215
- - Enrollment parameters are unchanged and keep working alongside: `{{ params.X }}` still renders into the channel config, including a templated `base_url` query.
216
- - The platform's own services are terminated in gateway stages before the upstream URI is built, so each stage reads its options from the request's arguments (`log_dispatch` drops entries below `log_level`).
217
- - SMTP has no query string; options for pure-SMTP delivery are out of scope.
214
+ - **A service implemented in the gateway** (`me`, `log`, `ai`, `notify`, `email`, `bill`) reads the calling key's policy from the routing context and applies the option itself (`me` gives values only for the grant's `profile` paths; `log` would drop entries below a grant's `log_level`).
215
+ - **A proxied service** can only be influenced through its own upstream's interface, so it applies an option only where its configuration maps it into the upstream request in a form that upstream understands, for example a request transformer setting a body field. Until such a mapping exists, options are enforced by gateway-implemented services only.
216
+ - The policy itself never reaches a seller: a seller's upstream sees at most the effect of an option, in its own vocabulary.
217
+ - An option the policy does not set takes the service's `default`. A plain `service:*` key sets none.
218
+ - Enrollment parameters are unchanged and keep working alongside: `{{ params.X }}` still renders into the channel config.
218
219
 
219
220
  **4. Where exports are published.** In the public catalog bundle: every service row carries its `exports` exactly as declared (`null` when none). `GET /v1/catalog/bundle` returns the bundle's URL; a `service:<name>` is the row whose `name` is `<name>`.
220
221
 
221
- **5. Validating `customer_access_requests`.** `validate_channel_access_requests(upstream_access_config, resolve)` checks every channel's requests. Limits and a default claim (`invoke`, no options) need no lookup. For any other claim, `resolve(name)` returns the target's `exports` from the catalog (`{}` when it declares none) or `None` when no such service is published; core parses it with `parse_exports` and checks the claimed permissions and the options (names, then values against its `options` schema) against it, refusing an unpublished target. Without a resolver only the shape is checked.
222
+ **5. Validating `customer_access_requests`.** `validate_channel_access_requests(upstream_access_config, resolve)` checks every channel's requests. Limits need no lookup. Every claim's permissions and options, `invoke` included (a service that declares permissions may not export it), are looked up: `resolve(name)` returns the target's `exports` from the catalog (`{}` when it declares none) or `None` when no such service is published; core parses it with `parse_exports` and checks the claimed permissions and the options (names, then values against its `options` schema) against it, refusing an unpublished target. Without a resolver only the shape is checked.
222
223
 
223
224
  - `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.
224
225
  - Backend ingest calls the same function with a resolver over the published services, so an unknown target, permission or option fails at upload.
226
+
227
+ ### Enrollment parameters on channels
228
+
229
+ A channel may declare its own enrollment parameters, so each plan of a service asks for what it needs (unitysvc/unitysvc#2726):
230
+
231
+ ```json
232
+ "upstream_access_config": {
233
+ "plus": {
234
+ "base_url": "https://u/{{ params.folder }}",
235
+ "params": {
236
+ "schema": {"type": "object", "properties": {"folder": {"type": "string"}}, "required": ["folder"]},
237
+ "ui_schema": {},
238
+ "testing_defaults": {"folder": "ops"}
239
+ }
240
+ }
241
+ }
242
+ ```
243
+
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`.
250
+
251
+ When no channel declares `params`, the listing-level `user_parameters_schema` applies to the whole service as before.
@@ -155,9 +155,10 @@ Three stages (unitysvc/unitysvc#2720): a target service's **`exports`** (what it
155
155
  }
156
156
  ```
157
157
 
158
- - `invoke` is always exported and covers every route; it may not be redeclared. A service that declares nothing (most of them) exports `invoke` only, with no options, which `service:<name>` or `service:*` covers.
159
- - `permissions`: a name → the routes it covers, each `"<METHOD> /<path>"` (`*` for any method, `*` globs in the path). Permissions are never inferred from HTTP methods.
160
- - `options`: a JSON Schema (draft 7) object of the query arguments the service accepts as customizations, 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 every value is one query argument: 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 option is not sent.
158
+ - **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
+ - **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
+ - `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.
161
162
 
162
163
  **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:
163
164
 
@@ -169,21 +170,47 @@ Three stages (unitysvc/unitysvc#2720): a target service's **`exports`** (what it
169
170
  ```
170
171
 
171
172
  - `object` names one service (`service:<name>`, no wildcards). `enrollment:<CODE>` (one enrollment of an enrollable service) may be allowed later.
172
- - `options` is a query string (`parse_options`): 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.
173
+ - `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.
173
174
  - **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.
174
175
  - `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.
175
176
 
176
- **3. How options reach the service.** No new channel: the upstream URL. When the gateway constructs the target's upstream URL for a call made under a policy entry, it appends that entry's `options` as query arguments, `?log_level=warn&max_size=2000`, the way query arguments already reach every upstream, `log://` included. Rules:
177
+ **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:
177
178
 
178
- - The gateway first strips any query argument the client sent under one of the target's exported option names, then appends the policy's values. Today the client's arguments win on a name collision, so without this a caller could override its own policy.
179
- - An option the policy does not set is not sent; the service applies its own default. A plain `service:*` key sends none.
180
- - Enrollment parameters are unchanged and keep working alongside: `{{ params.X }}` still renders into the channel config, including a templated `base_url` query.
181
- - The platform's own services are terminated in gateway stages before the upstream URI is built, so each stage reads its options from the request's arguments (`log_dispatch` drops entries below `log_level`).
182
- - SMTP has no query string; options for pure-SMTP delivery are out of scope.
179
+ - **A service implemented in the gateway** (`me`, `log`, `ai`, `notify`, `email`, `bill`) reads the calling key's policy from the routing context and applies the option itself (`me` gives values only for the grant's `profile` paths; `log` would drop entries below a grant's `log_level`).
180
+ - **A proxied service** can only be influenced through its own upstream's interface, so it applies an option only where its configuration maps it into the upstream request in a form that upstream understands, for example a request transformer setting a body field. Until such a mapping exists, options are enforced by gateway-implemented services only.
181
+ - The policy itself never reaches a seller: a seller's upstream sees at most the effect of an option, in its own vocabulary.
182
+ - An option the policy does not set takes the service's `default`. A plain `service:*` key sets none.
183
+ - Enrollment parameters are unchanged and keep working alongside: `{{ params.X }}` still renders into the channel config.
183
184
 
184
185
  **4. Where exports are published.** In the public catalog bundle: every service row carries its `exports` exactly as declared (`null` when none). `GET /v1/catalog/bundle` returns the bundle's URL; a `service:<name>` is the row whose `name` is `<name>`.
185
186
 
186
- **5. Validating `customer_access_requests`.** `validate_channel_access_requests(upstream_access_config, resolve)` checks every channel's requests. Limits and a default claim (`invoke`, no options) need no lookup. For any other claim, `resolve(name)` returns the target's `exports` from the catalog (`{}` when it declares none) or `None` when no such service is published; core parses it with `parse_exports` and checks the claimed permissions and the options (names, then values against its `options` schema) against it, refusing an unpublished target. Without a resolver only the shape is checked.
187
+ **5. Validating `customer_access_requests`.** `validate_channel_access_requests(upstream_access_config, resolve)` checks every channel's requests. Limits need no lookup. Every claim's permissions and options, `invoke` included (a service that declares permissions may not export it), are looked up: `resolve(name)` returns the target's `exports` from the catalog (`{}` when it declares none) or `None` when no such service is published; core parses it with `parse_exports` and checks the claimed permissions and the options (names, then values against its `options` schema) against it, refusing an unpublished target. Without a resolver only the shape is checked.
187
188
 
188
189
  - `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.
189
190
  - Backend ingest calls the same function with a resolver over the published services, so an unknown target, permission or option fails at upload.
191
+
192
+ ### Enrollment parameters on channels
193
+
194
+ A channel may declare its own enrollment parameters, so each plan of a service asks for what it needs (unitysvc/unitysvc#2726):
195
+
196
+ ```json
197
+ "upstream_access_config": {
198
+ "plus": {
199
+ "base_url": "https://u/{{ params.folder }}",
200
+ "params": {
201
+ "schema": {"type": "object", "properties": {"folder": {"type": "string"}}, "required": ["folder"]},
202
+ "ui_schema": {},
203
+ "testing_defaults": {"folder": "ops"}
204
+ }
205
+ }
206
+ }
207
+ ```
208
+
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`.
215
+
216
+ When no channel declares `params`, the listing-level `user_parameters_schema` applies to the whole service as before.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "unitysvc-core"
7
- version = "0.2.36"
7
+ version = "0.2.37"
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" }]
@@ -12,17 +12,24 @@ Three stages (unitysvc/unitysvc#2720):
12
12
  "max_size": {"type": "integer", "minimum": 1}}}
13
13
  }
14
14
 
15
- ``invoke`` is always exported and covers every route. A service that
16
- declares nothing exports just that: ``invoke``, no options, which
17
- ``service:<name>`` or ``service:*`` already covers. Permissions name the
18
- routes they cover; they are never inferred from HTTP methods
15
+ A service that declares no ``permissions`` has one implicit permission,
16
+ ``invoke``, covering every route (``"* /*"``); that is what
17
+ ``service:<name>`` and the ``service:*`` wildcard grant. A service that
18
+ declares ``permissions`` has exactly those, each covering the routes it
19
+ lists, and ``invoke`` only if it declares it too. A data service (``me``,
20
+ ``log``, ``needs``) therefore declares ``read`` / ``write`` and no
21
+ ``invoke``: holding the customer's own data, it is reached only by a grant
22
+ that names its permissions, never by ``invoke`` or a wildcard. Permissions
23
+ name the routes they cover; they are never inferred from HTTP methods
19
24
  (``POST /chat/completions`` modifies nothing). ``options`` is a JSON Schema,
20
25
  like an enrollment's ``user_parameters_schema`` (with an optional
21
- ``options_ui_schema``), of the query arguments the service accepts as
22
- customizations, including limits only it can apply (a message size); a
23
- property's ``default`` is what the service does when the option is not sent.
24
- Each property is a string, integer, number, boolean or array of strings,
25
- serialized as an OpenAPI form-style query parameter (arrays comma-joined).
26
+ ``options_ui_schema``), of the customizations a grant may set on the
27
+ service, including limits only it can apply (a message size); a property's
28
+ ``default`` is what the service does when the policy does not set the
29
+ option. Each property is a string, integer, number, boolean or array of
30
+ strings, so a grant can write its options in query-string syntax
31
+ (``"log_level=warn"``, arrays comma-joined). That is only how a grant stores
32
+ them, not how they reach the service (below).
26
33
 
27
34
  * **Customer access requests** — what a seller asks the enrolling customer to
28
35
  approve: to use other services through that customer's account and
@@ -48,11 +55,18 @@ customer's delegated identity, within the approved permissions, options and
48
55
  limits. Looking up a target's ``exports`` validates a request's *capabilities*;
49
56
  customer consent and key issuance establish its *authority*.
50
57
 
51
- * **Options travel as a query string.** When the gateway constructs the target's
52
- upstream URL, it appends the policy entry's ``options`` (``?log_level=warn``),
53
- so they reach any upstream, ``log://`` included, the way query arguments
54
- already do. An option the policy does not set is left to the service's own
55
- default.
58
+ * **The service applies its options; the upstream URL is never the channel.**
59
+ The gateway does not change a target's upstream URL (its ``base_url`` or
60
+ query) to carry policy. A service implemented in the gateway (``me``,
61
+ ``log``, ``ai``, ``notify``, ``email``, ``bill``) reads the calling key's
62
+ policy from the routing context and applies the option itself. A proxied
63
+ service can only be influenced through its own upstream's interface, so it
64
+ applies an option only where its configuration maps it into the upstream
65
+ request in a form that upstream understands (for example a request
66
+ transformer setting a body field); until such a mapping exists, options are
67
+ enforced by gateway-implemented services only. Either way the policy itself
68
+ never reaches a seller. An option the policy does not set takes the
69
+ service's ``default``.
56
70
 
57
71
  * **Limits are universal**: the platform meters every call, so any service can
58
72
  be limited by ``count`` or ``amount`` per hour, day or month, or in total,
@@ -61,12 +75,13 @@ customer consent and key issuance establish its *authority*.
61
75
  Today a request's ``object`` is ``service:<name>``; ``enrollment:<CODE>`` (one
62
76
  enrollment of an enrollable service) may follow.
63
77
 
64
- There is no central list of services. Core does no I/O: a request that claims
65
- more than the default (a permission other than ``invoke``, or any option) is
66
- checked against its target's ``exports`` when the caller passes an
67
- :data:`ExportsResolver` that looks the target up in the published catalog
68
- bundle, where every service row carries its ``exports``. Limits need no lookup.
69
- Without a resolver, only the shape of each entry is checked.
78
+ There is no central list of services. Core does no I/O: every request's
79
+ permissions and options are checked against its target's ``exports`` when the
80
+ caller passes an :data:`ExportsResolver` that looks the target up in the
81
+ published catalog bundle, where every service row carries its ``exports``. An
82
+ ``invoke`` claim is looked up too, since a service that declares permissions
83
+ may not export ``invoke``. Limits need no lookup. Without a resolver, only the
84
+ shape of each entry is checked.
70
85
  """
71
86
 
72
87
  from __future__ import annotations
@@ -94,8 +109,10 @@ ExportsResolver = Callable[[str], dict[str, Any] | None]
94
109
  or ``None`` when the catalog has no such service."""
95
110
 
96
111
  _ROUTE = re.compile(r"^(\*|GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS) /\S*$")
97
- # An option is a query argument, so its name stays plain.
112
+ # A grant writes its options in query-string syntax, so an option's name stays
113
+ # plain; so does a permission's.
98
114
  _OPTION_NAME = re.compile(r"^[a-z][a-z0-9_]*$")
115
+ _PERMISSION_NAME = _OPTION_NAME
99
116
  # ``count_per_day``, ``amount_per_month``, ``count_total`` ...
100
117
  _LIMIT = re.compile(r"^(count|amount)_(per_hour|per_day|per_month|total)$")
101
118
 
@@ -114,14 +131,17 @@ class Exports:
114
131
 
115
132
  @property
116
133
  def permission_names(self) -> tuple[str, ...]:
117
- return (INVOKE, *self.permissions)
134
+ return tuple(self.permissions)
118
135
 
119
136
  @property
120
137
  def option_names(self) -> tuple[str, ...]:
121
138
  return tuple(self.options.get("properties", {}))
122
139
 
123
140
 
124
- DEFAULT_EXPORTS = Exports({}, {})
141
+ # Every route, for the implicit ``invoke`` of a service that declares no permissions.
142
+ ALL_ROUTES = "* /*"
143
+ DEFAULT_PERMISSIONS: dict[str, tuple[str, ...]] = {INVOKE: (ALL_ROUTES,)}
144
+ DEFAULT_EXPORTS = Exports(DEFAULT_PERMISSIONS, {})
125
145
 
126
146
  _REQUEST_KEYS = frozenset({"object", "permissions", "required", "justification", "options", "limits"})
127
147
 
@@ -140,6 +160,9 @@ def parse_exports(where: str, declared: Any) -> Exports:
140
160
  """Read an offering's ``exports``, refusing a malformed one.
141
161
 
142
162
  ``None`` or ``{}`` is the default: ``invoke`` on every route, no options.
163
+ Without ``permissions`` (options only), ``invoke`` is still the one
164
+ permission. With ``permissions``, the service exports exactly those; it
165
+ may declare ``invoke`` among them.
143
166
  """
144
167
  if declared is None:
145
168
  return DEFAULT_EXPORTS
@@ -148,12 +171,15 @@ def parse_exports(where: str, declared: Any) -> Exports:
148
171
  if extra := set(declared) - {"permissions", "options", "options_ui_schema"}:
149
172
  raise ValueError(f"{where}: unknown keys {sorted(extra)}")
150
173
  permissions = declared.get("permissions", {})
151
- if not isinstance(permissions, dict):
152
- raise ValueError(f'{where}.permissions must be {{"<name>": ["<METHOD> /<path>", ...]}}')
174
+ if not isinstance(permissions, dict) or ("permissions" in declared and not permissions):
175
+ raise ValueError(
176
+ f'{where}.permissions must be {{"<name>": ["<METHOD> /<path>", ...]}} with at least one '
177
+ f"permission; omit it to export only '{INVOKE}' on every route"
178
+ )
153
179
  for name, routes in permissions.items():
154
180
  at = f"{where}.permissions.{name}"
155
- if name == INVOKE:
156
- raise ValueError(f"{at}: '{INVOKE}' is always exported and covers every route")
181
+ if not _PERMISSION_NAME.match(name):
182
+ raise ValueError(f"{at}: a permission name is lowercase letters, digits and '_'")
157
183
  if not _strings(routes) or not all(_ROUTE.match(r) for r in routes):
158
184
  raise ValueError(f"{at} must be a non-empty list of routes like 'GET /records/*'")
159
185
  options = declared.get("options", {})
@@ -162,6 +188,8 @@ def parse_exports(where: str, declared: Any) -> Exports:
162
188
  ui_schema = declared.get("options_ui_schema")
163
189
  if ui_schema is not None and not isinstance(ui_schema, dict):
164
190
  raise ValueError(f"{where}.options_ui_schema must be an object")
191
+ if not permissions:
192
+ return Exports(DEFAULT_PERMISSIONS, options)
165
193
  return Exports({name: tuple(routes) for name, routes in permissions.items()}, options)
166
194
 
167
195
 
@@ -185,7 +213,7 @@ def _check_options_schema(where: str, schema: Any) -> None:
185
213
  for name, prop in schema["properties"].items():
186
214
  at = f"{where}.properties.{name}"
187
215
  if not _OPTION_NAME.match(name):
188
- raise ValueError(f"{at}: an option is a query argument; name it in lowercase letters, digits and '_'")
216
+ raise ValueError(f"{at}: an option name is lowercase letters, digits and '_'")
189
217
  kind = prop.get("type") if isinstance(prop, dict) else None
190
218
  if not isinstance(kind, str):
191
219
  raise ValueError(f"{at}.type must be one type, one of {sorted(_SCALAR_TYPES | {'array'})}")
@@ -309,10 +337,11 @@ def validate_access_requests(where: str, requests: Any, resolve: ExportsResolver
309
337
 
310
338
  ``where`` prefixes error messages
311
339
  (``upstream_access_config.<channel>.customer_access_requests``).
312
- With ``resolve``, an entry claiming more than the default (a permission
313
- other than ``invoke``, or any option) is checked against its target's
314
- ``exports``, and an unknown target is refused; a default claim and limits
315
- need no lookup. Without ``resolve``, only the shape is checked.
340
+ With ``resolve``, every entry's permissions and options are checked
341
+ against its target's ``exports`` (``invoke`` included: a service that
342
+ declares permissions may not export it), and an unknown target is
343
+ refused. Limits need no lookup. Without ``resolve``, only the shape is
344
+ checked.
316
345
  """
317
346
  if not isinstance(requests, list):
318
347
  raise ValueError(f"{where} must be a list of access request objects")
@@ -346,23 +375,31 @@ def validate_access_requests(where: str, requests: Any, resolve: ExportsResolver
346
375
  raise ValueError(f"{at}.permissions must be a non-empty list of distinct names")
347
376
  options = parse_options(f"{at}.options", entry.get("options", ""))
348
377
  validate_limits(f"{at}.limits", entry.get("limits", {}))
349
- # A default claim (``invoke``, no options) is what every service
350
- # exports, so only a claim beyond it needs the target's exports.
351
- if resolve is None or (set(permissions) <= {INVOKE} and not options):
378
+ if resolve is None:
352
379
  continue
353
380
  declared = resolve(name)
354
381
  if declared is None:
355
382
  raise ValueError(f"{at}: {obj} is not a published service")
356
- target = parse_exports(f"{obj} exports", declared)
357
- if not set(permissions) <= set(target.permission_names):
358
- raise ValueError(f"{at}.permissions: {obj} exports only {list(target.permission_names)}")
359
- if unknown := sorted(set(options) - set(target.option_names)):
360
- raise ValueError(
361
- f"{at}.options: {obj} accepts no option {unknown[0]!r} "
362
- f"(options: {sorted(target.option_names) or 'none'})"
363
- )
364
- if error := _options_error(target.options, options):
365
- raise ValueError(f"{at}.options: {error}")
383
+ check_claim(at, obj, permissions, options, parse_exports(f"{obj} exports", declared))
384
+
385
+
386
+ def check_claim(where: str, obj: str, permissions: list[str], options: dict[str, str], exports: Exports) -> None:
387
+ """Refuse a claim on ``obj`` (``service:<name>``) that its ``exports`` don't cover.
388
+
389
+ The check shared by a seller's access request and a key's policy entry:
390
+ every permission must be one the target exports (``invoke`` only if it
391
+ does), and every option, already split by :func:`parse_options`, must be
392
+ one it declares, with a value valid for that option's schema.
393
+ """
394
+ if not set(permissions) <= set(exports.permission_names):
395
+ raise ValueError(f"{where}.permissions: {obj} exports only {list(exports.permission_names)}")
396
+ if unknown := sorted(set(options) - set(exports.option_names)):
397
+ raise ValueError(
398
+ f"{where}.options: {obj} accepts no option {unknown[0]!r} "
399
+ f"(options: {sorted(exports.option_names) or 'none'})"
400
+ )
401
+ if error := _options_error(exports.options, options):
402
+ raise ValueError(f"{where}.options: {error}")
366
403
 
367
404
 
368
405
  def validate_channel_access_requests(config: Any, resolve: ExportsResolver | None = None) -> None:
@@ -108,9 +108,11 @@ class ServiceOfferingData(BaseModel):
108
108
  description=(
109
109
  "How other services may use this one (what a customer access request can ask for): "
110
110
  '{"permissions": {<name>: ["<METHOD> /<path>", ...]}, '
111
- '"options": <JSON Schema object of the query arguments it accepts>, '
111
+ '"options": <JSON Schema object of the options a grant may set>, '
112
112
  '"options_ui_schema": <rjsf uiSchema>}. '
113
- "invoke is always exported and covers every route; omitted: invoke only, no options. "
113
+ "Without permissions, the service exports one, invoke, on every route; with permissions, "
114
+ "exactly those (invoke only if declared), so a data service declaring read/write is never "
115
+ "reached by invoke or service:*. Omitted: invoke only, no options. "
114
116
  "See unitysvc_core.grants."
115
117
  ),
116
118
  )