@palettelab/cli 0.3.51 → 0.3.53

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 (32) hide show
  1. package/README.md +45 -1
  2. package/backend-sdk/palette_sdk/__init__.py +4 -0
  3. package/backend-sdk/palette_sdk/events.py +16 -0
  4. package/backend-sdk/palette_sdk/manifest.py +2 -0
  5. package/backend-sdk/palette_sdk/services.py +26 -2
  6. package/docs/python-backend-sdk.md +64 -5
  7. package/lib/commands/init.js +1 -1
  8. package/lib/commands/services.js +255 -17
  9. package/lib/dev-simulator.js +12 -0
  10. package/lib/manifest.js +18 -2
  11. package/package.json +1 -1
  12. package/template-fallback/package.json +1 -1
  13. package/template-fallback/templates/consumer-app/README.md +10 -0
  14. package/template-fallback/templates/consumer-app/backend/api/main.py +15 -0
  15. package/template-fallback/templates/consumer-app/frontend/src/index.tsx +26 -0
  16. package/template-fallback/templates/consumer-app/package.json +9 -0
  17. package/template-fallback/templates/consumer-app/palette-plugin.json +45 -0
  18. package/template-fallback/templates/consumer-app/pyproject.toml +11 -0
  19. package/template-fallback/templates/dashboard/package.json +1 -1
  20. package/template-fallback/templates/database/package.json +1 -1
  21. package/template-fallback/templates/external-service/package.json +1 -1
  22. package/template-fallback/templates/frontend-only/package.json +1 -1
  23. package/template-fallback/templates/next/package.json +1 -1
  24. package/template-fallback/templates/palette-app/package.json +1 -1
  25. package/template-fallback/templates/provider-app/README.md +10 -0
  26. package/template-fallback/templates/provider-app/backend/api/main.py +25 -0
  27. package/template-fallback/templates/provider-app/package.json +8 -0
  28. package/template-fallback/templates/provider-app/palette-plugin.json +49 -0
  29. package/template-fallback/templates/provider-app/pyproject.toml +11 -0
  30. package/template-fallback/templates/provider-app/schemas/approvalChain.get.in.json +8 -0
  31. package/template-fallback/templates/provider-app/schemas/approvalChain.get.out.json +13 -0
  32. package/template-fallback/templates/provider-app/schemas/hierarchy.updated.json +8 -0
package/README.md CHANGED
@@ -783,10 +783,19 @@ If no plugin ID is provided, the CLI uses the current `palette-plugin.json` or `
783
783
  Inspect and generate OS-broker service integrations declared through
784
784
  `provides` and `consumes` in `palette-plugin.json`.
785
785
 
786
+ Use this command family when one Palette app needs governed data, approvals, or
787
+ events from another app. The CLI keeps the app manifest, local mocks, provider
788
+ schema files, and generated clients aligned with the broker contract.
789
+
786
790
  ```bash
791
+ pltt init employee-management --template provider-app
792
+ pltt init leave-management --template consumer-app
793
+ pltt services sync
787
794
  pltt services list --env staging
788
795
  pltt services add hr/v1#approvalChain.get --reason "Route approvals through HR"
789
796
  pltt services add hr/v1#hierarchy.updated --event --optional
797
+ pltt services mock hr/v1#approvalChain.get
798
+ pltt services test --offline
790
799
  pltt services pull --env staging
791
800
  pltt services scaffold approvalChain.get
792
801
  ```
@@ -795,13 +804,48 @@ Subcommands:
795
804
 
796
805
  - `list`: fetches `/api/v1/os-broker/catalog` from the selected environment and prints services/events available to the current org. Use `--json` for the raw catalog.
797
806
  - `add <namespace/version#name>`: appends a consumed service target to `consumes.services`. Use `--event` or `--type event` for `consumes.events`, `--optional` for non-required dependencies, and `--reason <text>` to record why the dependency is needed.
798
- - `pull`: reads `consumes.services` and `consumes.events`, fetches JSON Schemas from `/api/v1/os-broker/schemas`, and generates `.palette/types/services.ts` plus `.palette/python/services.py`.
807
+ - `sync`: scans backend `@service(...)` decorators and updates `provides.services` so provider contracts do not drift from code.
808
+ - `mock <namespace/version#name>`: adds a local response to `.palette/app-services.local.json` for `pltt dev`.
809
+ - `test`: checks local consumes/provides/mocks and, unless `--offline` is set, verifies consumed targets against `/api/v1/os-broker/schemas`.
810
+ - `pull`: reads `consumes.services` and `consumes.events`, fetches JSON Schemas from `/api/v1/os-broker/schemas`, and generates nested `.palette/types/services.ts` plus Python helpers in `.palette/python/services.py`.
799
811
  - `scaffold <method>`: adds a provider method to `provides.services`, creates `schemas/<method>.in.json` and `schemas/<method>.out.json`, and writes a Python handler stub under `broker/`.
800
812
 
801
813
  Generated TypeScript clients use the SDK's default `palette` client. Generated
802
814
  Python clients call `palette_sdk.services(ctx)`, so backend routes still pass
803
815
  through Palette's broker permission and install checks.
804
816
 
817
+ Recommended workflow:
818
+
819
+ 1. Provider app: implement backend methods with `@service(...)` and declare
820
+ event topics in `provides.events`.
821
+ 2. Provider app: run `pltt services sync` or `pltt services scaffold <method>`
822
+ to keep manifest methods and schema files current.
823
+ 3. Consumer app: run `pltt services add <target>` for each consumed service or
824
+ event, including a `--reason` for review and install UI.
825
+ 4. Consumer app: use `pltt services mock <target>` and `pltt services test
826
+ --offline` during local development.
827
+ 5. Consumer app: use `pltt services pull --env staging` to generate typed
828
+ clients from the live `/api/v1/os-broker/schemas` endpoint.
829
+ 6. Run `pltt test` before publish; contract checks include local
830
+ `provides`/`consumes`, mocks, schemas, and dependency-policy validation.
831
+
832
+ Runtime behavior:
833
+
834
+ - Service calls go through `POST /api/v1/os-broker/dispatch`.
835
+ - Event publishes go through `POST /api/v1/os-broker/events/emit`.
836
+ - Event subscriptions use `GET /api/v1/os-broker/events/stream`.
837
+ - Catalog and generated client metadata come from `/api/v1/os-broker/catalog`
838
+ and `/api/v1/os-broker/schemas`.
839
+ - Installs use `/api/v1/app-installs/<app_id>/dependency-plan` and can include
840
+ required provider apps with `?include_dependencies=true`.
841
+ - Org owners/admins can revoke or restore individual cross-app grants from
842
+ Settings > Apps; the broker checks those grants on every call and stream.
843
+
844
+ Provider methods can use inline JSON Schema objects or schema file paths in
845
+ `input_schema`, `input`, `output_schema`, and `output`. Event topics can use
846
+ `schema` or `payload_schema`. The CLI scaffolds schema files by default because
847
+ they are easier to review and reuse for generated clients.
848
+
805
849
  ## Global Flags
806
850
 
807
851
  - `--json` emits machine-readable output for `package`, `publish`, `status`, `logs`, `test`, and `version`.
@@ -38,6 +38,8 @@ from palette_sdk import events as events_module # also exposed as `palette_sdk.
38
38
  from palette_sdk.events import Event, EventPublisher, subscribe_event
39
39
  from palette_sdk.services import (
40
40
  BrokerCallError,
41
+ CrossAppGrantError,
42
+ MissingDependencyError,
41
43
  ServicesClient,
42
44
  service,
43
45
  services,
@@ -89,6 +91,8 @@ __all__ = [
89
91
  "services",
90
92
  "ServicesClient",
91
93
  "BrokerCallError",
94
+ "CrossAppGrantError",
95
+ "MissingDependencyError",
92
96
  "get_config",
93
97
  "require_config",
94
98
  "sign_webhook",
@@ -98,3 +98,19 @@ class EventPublisher:
98
98
  # Alias: `events.emit(...)` reads more naturally for app-owned events.
99
99
  async def emit(self, topic: str, payload: dict[str, Any] | None = None) -> None:
100
100
  await self.publish(topic, payload)
101
+
102
+ async def publish_durable(self, topic: str, payload: dict[str, Any] | None = None) -> None:
103
+ """Publish a business event through the broker durable-event API.
104
+
105
+ Current platform versions route this through the standard broker event
106
+ publisher. The method is intentionally stable so apps can adopt durable
107
+ delivery without changing call sites when queue-backed delivery is
108
+ enabled by the platform.
109
+ """
110
+ if hasattr(self._adapter, "publish_durable"):
111
+ await self._adapter.publish_durable(topic, payload or {})
112
+ return
113
+ await self.publish(topic, payload)
114
+
115
+ async def emit_durable(self, topic: str, payload: dict[str, Any] | None = None) -> None:
116
+ await self.publish_durable(topic, payload)
@@ -113,6 +113,8 @@ class AppServiceMethodSpec(BaseModel):
113
113
  output: dict[str, Any] | str | None = None
114
114
  route_method: Literal["GET", "POST", "PUT", "PATCH", "DELETE"] | None = None
115
115
  route_path: str | None = None
116
+ deprecated: bool = False
117
+ replacement: str | None = None
116
118
 
117
119
 
118
120
  class ProvidedAppServiceSpec(BaseModel):
@@ -35,11 +35,15 @@ ServiceHandler = Callable[[Any, dict[str, Any]], Awaitable[Any]]
35
35
  class ServiceDecl:
36
36
  name: str
37
37
  handler: ServiceHandler
38
+ namespace: str | None = None
39
+ version: str | None = None
38
40
  scope: str | None = None
39
41
  label: str | None = None
40
42
  description: str | None = None
41
43
  input_schema: dict | None = None
42
44
  output_schema: dict | None = None
45
+ deprecated: bool = False
46
+ replacement: str | None = None
43
47
 
44
48
 
45
49
  _pending: list[ServiceDecl] = []
@@ -48,11 +52,17 @@ _pending: list[ServiceDecl] = []
48
52
  def service(
49
53
  name: str,
50
54
  *,
55
+ namespace: str | None = None,
56
+ version: str | None = None,
51
57
  scope: str | None = None,
52
58
  label: str | None = None,
53
59
  description: str | None = None,
54
60
  input_schema: dict | None = None,
61
+ input: dict | None = None,
55
62
  output_schema: dict | None = None,
63
+ output: dict | None = None,
64
+ deprecated: bool = False,
65
+ replacement: str | None = None,
56
66
  ) -> Callable[[ServiceHandler], ServiceHandler]:
57
67
  """Mark an async function as a broker-callable service method."""
58
68
 
@@ -61,11 +71,15 @@ def service(
61
71
  ServiceDecl(
62
72
  name=name,
63
73
  handler=fn,
74
+ namespace=namespace,
75
+ version=version,
64
76
  scope=scope,
65
77
  label=label,
66
78
  description=description,
67
- input_schema=input_schema,
68
- output_schema=output_schema,
79
+ input_schema=input_schema or input,
80
+ output_schema=output_schema or output,
81
+ deprecated=deprecated,
82
+ replacement=replacement,
69
83
  )
70
84
  )
71
85
  return fn
@@ -84,6 +98,14 @@ class BrokerCallError(RuntimeError):
84
98
  """Raised when the broker rejects or fails a service call."""
85
99
 
86
100
 
101
+ class MissingDependencyError(BrokerCallError):
102
+ """Raised by generated clients when a required provider app is missing."""
103
+
104
+
105
+ class CrossAppGrantError(BrokerCallError):
106
+ """Raised by generated clients when an org admin revoked a cross-app grant."""
107
+
108
+
87
109
  class ServicesClient:
88
110
  """Returned by `services(ctx)`. Use `.call(target, payload)` or sugar `.proxy(...)`."""
89
111
 
@@ -139,6 +161,8 @@ def services(ctx_or_adapter: Any) -> ServicesClient:
139
161
 
140
162
  __all__ = [
141
163
  "BrokerCallError",
164
+ "CrossAppGrantError",
165
+ "MissingDependencyError",
142
166
  "ServiceDecl",
143
167
  "ServicesClient",
144
168
  "drain_pending_services",
@@ -693,7 +693,18 @@ token = await ctx.connections.access_token("google_calendar")
693
693
  ## 11. App-To-App Services
694
694
 
695
695
  Apps can expose governed broker services and events, then consume them from
696
- other installed apps without knowing another app's URL.
696
+ other installed apps without knowing another app's URL. The broker is the only
697
+ supported integration path for cross-app business data. Do not read another
698
+ app's database tables and do not call another app's private backend route
699
+ directly.
700
+
701
+ Palette enforces the app-to-app contract in four places:
702
+
703
+ - Manifest review: providers declare `provides`, consumers declare `consumes`.
704
+ - Install: required provider apps are resolved before the consumer is activated.
705
+ - Org grants: owners/admins can allow or revoke each consumed service/event.
706
+ - Runtime: every service call, event emit, and event stream is checked against
707
+ same-org install state, `consumes`, grants, and JSON Schemas.
697
708
 
698
709
  Provider apps declare a namespace and callable methods with `provides`:
699
710
 
@@ -705,25 +716,40 @@ Provider apps declare a namespace and callable methods with `provides`:
705
716
  {
706
717
  "id": "hr.directory",
707
718
  "methods": [
708
- { "name": "approvalChain.get", "input_schema": "schemas/approval-chain.input.json" }
719
+ {
720
+ "name": "approvalChain.get",
721
+ "input_schema": "schemas/approval-chain.input.json",
722
+ "output_schema": "schemas/approval-chain.output.json"
723
+ }
709
724
  ]
710
725
  }
711
726
  ],
712
- "events": [{ "topic": "hierarchy.updated" }]
727
+ "events": [
728
+ {
729
+ "topic": "hierarchy.updated",
730
+ "schema": "schemas/hierarchy-updated.json"
731
+ }
732
+ ]
713
733
  }
714
734
  }
715
735
  ```
716
736
 
717
- Expose the handler with `@service`:
737
+ Expose the handler with `@service`. The platform registers handlers when the
738
+ plugin loads and injects a full `PluginContext` at dispatch time:
718
739
 
719
740
  ```python
720
741
  from palette_sdk import PluginContext, service
721
742
 
722
- @service("approvalChain.get")
743
+ @service("approvalChain.get", scope="members:read")
723
744
  async def approval_chain(ctx: PluginContext, payload: dict) -> dict:
724
745
  return {"approvers": [{"user_id": ctx.user_id, "step": 1}]}
725
746
  ```
726
747
 
748
+ If a method also declares `route_method` and `route_path`, Palette can fall back
749
+ to signed internal HTTP transport when no in-process handler is registered. New
750
+ provider apps should prefer `@service(...)` because it avoids URL coupling and
751
+ keeps dispatch inside the broker lifecycle.
752
+
727
753
  Consumers declare qualified targets with `consumes`:
728
754
 
729
755
  ```json
@@ -746,8 +772,11 @@ Consumers declare qualified targets with `consumes`:
746
772
  The CLI can update the manifest and generate typed clients for those targets:
747
773
 
748
774
  ```bash
775
+ pltt services sync
749
776
  pltt services add hr/v1#approvalChain.get --reason "Route leave approvals through HR"
750
777
  pltt services add hr/v1#hierarchy.updated --event --optional
778
+ pltt services mock hr/v1#approvalChain.get
779
+ pltt services test --offline
751
780
  pltt services pull --env staging
752
781
  ```
753
782
 
@@ -764,12 +793,42 @@ from palette_sdk import services
764
793
 
765
794
  chain = await services(ctx).call("hr/v1#approvalChain.get", {"user_id": ctx.user_id})
766
795
  await ctx.events.emit("leave/v1#leave.requested", {"approval_chain": chain})
796
+ await ctx.events.emit_durable("leave/v1#leave.requested", {"approval_chain": chain})
767
797
  ```
768
798
 
769
799
  At install time, Palette checks required `consumes` targets and can show the
770
800
  provider apps that must also be installed. The org-level grant is saved on the
771
801
  install and the broker checks it on every call, emit, or event stream.
772
802
 
803
+ Useful platform endpoints when building or debugging integrations:
804
+
805
+ ```text
806
+ GET /api/v1/app-installs/{app_id}/dependency-plan
807
+ POST /api/v1/app-installs/{app_id}?include_dependencies=true
808
+ GET /api/v1/app-installs/{app_id}/cross-app-grants
809
+ PATCH /api/v1/app-installs/{app_id}/cross-app-grants
810
+ GET /api/v1/os-broker/catalog
811
+ GET /api/v1/os-broker/schemas?targets=hr/v1#approvalChain.get
812
+ GET /api/v1/os-broker/audit?app_id=leave-management
813
+ ```
814
+
815
+ Local simulator workflows can use `.palette/app-services.local.json` mocks.
816
+ The mock command writes the file for you:
817
+
818
+ ```bash
819
+ pltt services mock hr/v1#approvalChain.get
820
+ ```
821
+
822
+ Example mock:
823
+
824
+ ```json
825
+ {
826
+ "hr/v1#approvalChain.get": {
827
+ "approvers": [{ "user_id": "manager-1", "step": 1 }]
828
+ }
829
+ }
830
+ ```
831
+
773
832
  App storage is separate from Data Rooms. Use `ctx.storage` and `palette.storage` for app-owned files that go directly to the OS-configured storage backend, currently GCS in hosted environments. Use `ctx.data_rooms` or `palette.dataRooms` only when the file should be visible and governed as a Data Room document.
774
833
 
775
834
  Palette scopes storage the same way. Files written through `ctx.storage` or the
@@ -10,7 +10,7 @@ const DEFAULT_TEMPLATE_REPO = "palette-lab/plugin-template"
10
10
  const TEMPLATE_REPO = process.env.PALETTE_TEMPLATE_REPO || DEFAULT_TEMPLATE_REPO
11
11
  const TEMPLATE_REF = process.env.PALETTE_TEMPLATE_REF || "main"
12
12
 
13
- const KNOWN_TEMPLATES = ["frontend-only", "palette-app", "next", "dashboard", "agent-tool", "external-service", "database"]
13
+ const KNOWN_TEMPLATES = ["frontend-only", "palette-app", "next", "dashboard", "agent-tool", "external-service", "database", "provider-app", "consumer-app"]
14
14
 
15
15
  function toSlug(name) {
16
16
  return name
@@ -180,6 +180,54 @@ function safeIdent(s) {
180
180
  return s.replace(/[^A-Za-z0-9_]/g, "_")
181
181
  }
182
182
 
183
+ function safePythonIdent(s) {
184
+ return safeIdent(s).replace(/^(?=\d)/, "_")
185
+ }
186
+
187
+ function pythonMethodName(s) {
188
+ return safePythonIdent(
189
+ s
190
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
191
+ .replace(/[.-]+/g, "_")
192
+ .toLowerCase(),
193
+ )
194
+ }
195
+
196
+ function nestedAssign(root, dotted, value) {
197
+ const parts = dotted.split(".").filter(Boolean)
198
+ let cursor = root
199
+ while (parts.length > 1) {
200
+ const part = parts.shift()
201
+ cursor[part] = cursor[part] || {}
202
+ cursor = cursor[part]
203
+ }
204
+ cursor[parts[0] || dotted] = value
205
+ }
206
+
207
+ function renderTsObject(node, indent = " ") {
208
+ const lines = ["{"]
209
+ for (const [key, value] of Object.entries(node)) {
210
+ const prop = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key)
211
+ if (value && value.__service) {
212
+ if (value.scope) lines.push(`${indent}/** scope: ${value.scope} */`)
213
+ lines.push(`${indent}async ${prop}(input: ${value.inT}): Promise<${value.outT}> {`)
214
+ lines.push(`${indent} return (await palette.broker.call(${JSON.stringify(value.target)}, input)) as ${value.outT}`)
215
+ lines.push(`${indent}},`)
216
+ } else if (value && value.__event) {
217
+ lines.push(`${indent}${prop}(handler: (payload: ${value.payT}) => void) {`)
218
+ lines.push(`${indent} return palette.events.on(${JSON.stringify(value.target)}, (payload) => handler(payload as ${value.payT}))`)
219
+ lines.push(`${indent}},`)
220
+ } else {
221
+ const rendered = renderTsObject(value, indent + " ").split("\n")
222
+ lines.push(`${indent}${prop}: ${rendered[0]}`)
223
+ for (const line of rendered.slice(1)) lines.push(line)
224
+ lines[lines.length - 1] += ","
225
+ }
226
+ }
227
+ lines.push(`${indent.slice(0, -2)}}`)
228
+ return lines.join("\n")
229
+ }
230
+
183
231
  function tsHeader() {
184
232
  return (
185
233
  "// AUTOGENERATED by `pltt services pull` — do not edit by hand.\n" +
@@ -194,7 +242,7 @@ function pyHeader() {
194
242
  "from __future__ import annotations\n\n" +
195
243
  "from typing import Any\n\n" +
196
244
  "from pydantic import BaseModel\n" +
197
- "from palette_sdk import services as _services\n\n"
245
+ "from palette_sdk import services as _palette_services\n\n"
198
246
  )
199
247
  }
200
248
 
@@ -240,24 +288,28 @@ async function runPull(argv, { cwd }) {
240
288
  const services = items.filter((i) => i.kind === "service")
241
289
  const events = items.filter((i) => i.kind === "event")
242
290
  if (services.length) {
243
- tsLines.push(`export const ${safe} = {`)
291
+ const tree = {}
244
292
  for (const svc of services) {
245
- const comment = svc.scope ? ` /** scope: ${svc.scope} */` : ""
246
- if (comment) tsLines.push(comment)
247
- tsLines.push(` async ${JSON.stringify(svc.method)}(input: ${svc.inT}): Promise<${svc.outT}> {`)
248
- tsLines.push(` return (await palette.broker.call(${JSON.stringify(ns + "#" + svc.method)}, input)) as ${svc.outT}`)
249
- tsLines.push(` },`)
293
+ nestedAssign(tree, svc.method, {
294
+ __service: true,
295
+ target: `${ns}#${svc.method}`,
296
+ inT: svc.inT,
297
+ outT: svc.outT,
298
+ scope: svc.scope,
299
+ })
250
300
  }
251
- tsLines.push(`}\n`)
301
+ tsLines.push(`export const ${safe} = ${renderTsObject(tree)}\n`)
252
302
  }
253
303
  if (events.length) {
254
- tsLines.push(`export const ${safe}Events = {`)
304
+ const tree = {}
255
305
  for (const evt of events) {
256
- tsLines.push(` on${safeIdent(evt.topic).replace(/^(.)/, (m) => m.toUpperCase())}(handler: (payload: ${evt.payT}) => void) {`)
257
- tsLines.push(` return palette.events.on(${JSON.stringify(ns + "#" + evt.topic)}, (payload) => handler(payload as ${evt.payT}))`)
258
- tsLines.push(` },`)
306
+ nestedAssign(tree, evt.topic, {
307
+ __event: true,
308
+ target: `${ns}#${evt.topic}`,
309
+ payT: evt.payT,
310
+ })
259
311
  }
260
- tsLines.push(`}\n`)
312
+ tsLines.push(`export const ${safe}Events = ${renderTsObject(tree)}\n`)
261
313
  }
262
314
  }
263
315
  const tsDir = path.join(cwd, ".palette", "types")
@@ -270,7 +322,7 @@ async function runPull(argv, { cwd }) {
270
322
  const entry = byTarget.get(target)
271
323
  if (!entry) continue
272
324
  const safeNs = safeIdent(`${entry.namespace}_${entry.version}`)
273
- const safeMethod = safeIdent(entry.method || entry.topic)
325
+ const safeMethod = pythonMethodName(entry.method || entry.topic)
274
326
  if (entry.kind === "service") {
275
327
  const { code: inCode, ref: inRef } = jsonSchemaToPydantic(`${safeNs}_${safeMethod}_Input`, entry.input_schema)
276
328
  const { code: outCode, ref: outRef } = jsonSchemaToPydantic(`${safeNs}_${safeMethod}_Output`, entry.output_schema)
@@ -280,7 +332,7 @@ async function runPull(argv, { cwd }) {
280
332
  `async def ${safeNs}_${safeMethod}(ctx, payload: ${inRef}) -> ${outRef}:\n` +
281
333
  ` """Call ${entry.namespace}/${entry.version}#${entry.method}.\n\n` +
282
334
  (entry.scope ? ` Scope: ${entry.scope}\n """\n` : ` """\n`) +
283
- ` return await _services.services(ctx).call(${JSON.stringify(target)}, payload if isinstance(payload, dict) else payload.model_dump())\n`,
335
+ ` return await _palette_services(ctx).call(${JSON.stringify(target)}, payload if isinstance(payload, dict) else payload.model_dump())\n`,
284
336
  )
285
337
  } else if (entry.kind === "event") {
286
338
  const { code: payCode, ref: payRef } = jsonSchemaToPydantic(`${safeNs}_${safeMethod}_Payload`, entry.payload_schema)
@@ -303,6 +355,185 @@ async function runPull(argv, { cwd }) {
303
355
  if (missing.length) process.exit(2)
304
356
  }
305
357
 
358
+ // ---- sync provider contracts from Python decorators -------------------------
359
+
360
+ function listPythonFiles(dir, out = []) {
361
+ if (!fs.existsSync(dir)) return out
362
+ for (const name of fs.readdirSync(dir)) {
363
+ const p = path.join(dir, name)
364
+ const stat = fs.statSync(p)
365
+ if (stat.isDirectory()) {
366
+ if (![".venv", "__pycache__", ".palette", "node_modules"].includes(name)) listPythonFiles(p, out)
367
+ } else if (name.endsWith(".py")) {
368
+ out.push(p)
369
+ }
370
+ }
371
+ return out
372
+ }
373
+
374
+ function argValue(source, name) {
375
+ const match = source.match(new RegExp(`${name}\\s*=\\s*["']([^"']+)["']`))
376
+ return match ? match[1] : null
377
+ }
378
+
379
+ function boolArg(source, name) {
380
+ return new RegExp(`${name}\\s*=\\s*True`).test(source)
381
+ }
382
+
383
+ function discoverServiceDecorators(cwd) {
384
+ const files = listPythonFiles(path.join(cwd, "backend"))
385
+ const found = []
386
+ for (const file of files) {
387
+ const text = fs.readFileSync(file, "utf8")
388
+ const re = /@service\s*\(([\s\S]*?)\)\s*(?:async\s+)?def\s+([A-Za-z_][A-Za-z0-9_]*)/g
389
+ let match
390
+ while ((match = re.exec(text))) {
391
+ const args = match[1]
392
+ const name = (args.match(/["']([^"']+)["']/) || [])[1]
393
+ if (!name) continue
394
+ found.push({
395
+ name,
396
+ fn: match[2],
397
+ scope: argValue(args, "scope"),
398
+ label: argValue(args, "label"),
399
+ description: argValue(args, "description"),
400
+ namespace: argValue(args, "namespace"),
401
+ version: argValue(args, "version") || "v1",
402
+ input_schema: argValue(args, "input_schema") || argValue(args, "input"),
403
+ output_schema: argValue(args, "output_schema") || argValue(args, "output"),
404
+ deprecated: boolArg(args, "deprecated"),
405
+ replacement: argValue(args, "replacement"),
406
+ file: path.relative(cwd, file),
407
+ })
408
+ }
409
+ }
410
+ return found
411
+ }
412
+
413
+ async function runSync(argv, { cwd }) {
414
+ const manifest = readManifest(cwd)
415
+ const decorators = discoverServiceDecorators(cwd)
416
+ if (!decorators.length) {
417
+ console.log("[pltt] no @service(...) decorators found under backend/.")
418
+ return
419
+ }
420
+ manifest.provides = manifest.provides || {}
421
+ manifest.provides.namespace = valueAfter(argv, "--namespace") || decorators.find(d => d.namespace)?.namespace || manifest.provides.namespace || manifest.id
422
+ manifest.provides.services = manifest.provides.services || []
423
+ for (const decl of decorators) {
424
+ const serviceId = safeIdent(decl.name.split(".")[0] || decl.name).toLowerCase().replace(/_/g, ".")
425
+ let svc = manifest.provides.services.find(s => s && s.id === serviceId)
426
+ if (!svc) {
427
+ svc = { id: serviceId, version: decl.version || "v1", methods: [] }
428
+ manifest.provides.services.push(svc)
429
+ }
430
+ svc.version = svc.version || decl.version || "v1"
431
+ svc.methods = svc.methods || []
432
+ let method = svc.methods.find(m => m && m.name === decl.name)
433
+ if (!method) {
434
+ method = { name: decl.name }
435
+ svc.methods.push(method)
436
+ }
437
+ if (decl.scope) method.scope = decl.scope
438
+ if (decl.label) method.label = decl.label
439
+ if (decl.description) method.description = decl.description
440
+ if (decl.input_schema) method.input_schema = decl.input_schema
441
+ else method.input_schema = method.input_schema || `schemas/${decl.name}.in.json`
442
+ if (decl.output_schema) method.output_schema = decl.output_schema
443
+ else method.output_schema = method.output_schema || `schemas/${decl.name}.out.json`
444
+ if (decl.deprecated) method.deprecated = true
445
+ if (decl.replacement) method.replacement = decl.replacement
446
+ }
447
+ writeManifest(cwd, manifest)
448
+ console.log(`[pltt] synced ${decorators.length} service method(s) into palette-plugin.json`)
449
+ }
450
+
451
+ // ---- mock -------------------------------------------------------------------
452
+
453
+ function sampleFromSchema(schema) {
454
+ if (!schema || typeof schema !== "object") return {}
455
+ if (schema.example !== undefined) return schema.example
456
+ if (schema.type === "string") return "string"
457
+ if (schema.type === "integer" || schema.type === "number") return 1
458
+ if (schema.type === "boolean") return true
459
+ if (schema.type === "array") return [sampleFromSchema(schema.items)]
460
+ const props = schema.properties || {}
461
+ const out = {}
462
+ for (const [key, prop] of Object.entries(props)) out[key] = sampleFromSchema(prop)
463
+ return out
464
+ }
465
+
466
+ async function runMock(argv, { cwd }) {
467
+ const target = argv.find((arg) => !arg.startsWith("-"))
468
+ if (!target || !brokerTargetLooksValid(target)) {
469
+ console.error("[pltt] usage: pltt services mock <namespace/version#name>")
470
+ process.exit(1)
471
+ }
472
+ const mockPath = path.join(cwd, ".palette", "app-services.local.json")
473
+ ensureDir(path.dirname(mockPath))
474
+ const current = fs.existsSync(mockPath) ? JSON.parse(fs.readFileSync(mockPath, "utf8")) : {}
475
+ current[target] = current[target] || {
476
+ ok: true,
477
+ target,
478
+ items: [],
479
+ }
480
+ fs.writeFileSync(mockPath, JSON.stringify(current, null, 2) + "\n")
481
+ console.log(`[pltt] wrote local mock for ${target} -> ${path.relative(cwd, mockPath)}`)
482
+ }
483
+
484
+ // ---- test -------------------------------------------------------------------
485
+
486
+ function validateLocalMocks(cwd, targets) {
487
+ const mockPath = path.join(cwd, ".palette", "app-services.local.json")
488
+ if (!fs.existsSync(mockPath)) return []
489
+ const mocks = JSON.parse(fs.readFileSync(mockPath, "utf8"))
490
+ const errors = []
491
+ for (const key of Object.keys(mocks)) {
492
+ const target = key.split(" ")[0]
493
+ if (!brokerTargetLooksValid(target)) errors.push(`mock key is not a broker target: ${key}`)
494
+ if (!targets.has(target)) errors.push(`mock target is not declared in consumes: ${target}`)
495
+ }
496
+ return errors
497
+ }
498
+
499
+ async function runTest(argv, { cwd }) {
500
+ const manifest = readManifest(cwd)
501
+ const { services, events } = consumeTargets(manifest)
502
+ const targets = new Set([...services, ...events])
503
+ const errors = []
504
+ for (const target of targets) {
505
+ if (!brokerTargetLooksValid(target)) errors.push(`invalid broker target: ${target}`)
506
+ }
507
+ errors.push(...validateLocalMocks(cwd, targets))
508
+ const decorators = discoverServiceDecorators(cwd)
509
+ const provided = new Set()
510
+ const provides = manifest.provides || {}
511
+ const ns = provides.namespace || manifest.id
512
+ for (const svc of provides.services || []) {
513
+ for (const method of svc.methods || []) {
514
+ if (method.name) provided.add(`${ns}/${svc.version || "v1"}#${method.name}`)
515
+ }
516
+ }
517
+ for (const decl of decorators) {
518
+ const target = `${decl.namespace || ns}/${decl.version || "v1"}#${decl.name}`
519
+ if (!provided.has(target)) errors.push(`@service ${target} is not declared in provides; run pltt services sync`)
520
+ }
521
+ if (!argv.includes("--offline") && targets.size) {
522
+ const { flags } = parseFlags(argv)
523
+ const env = resolveEnvironment({ cwd, flags })
524
+ const schemas = await authFetch(env, `/api/v1/os-broker/schemas?targets=${encodeURIComponent(Array.from(targets).join(","))}`)
525
+ const returned = new Set(schemas.map(s => s.target))
526
+ for (const target of targets) {
527
+ if (!returned.has(target)) errors.push(`target not found in ${env.name || env.url}: ${target}`)
528
+ }
529
+ }
530
+ if (errors.length) {
531
+ for (const error of errors) console.error(`[pltt] ${error}`)
532
+ process.exit(1)
533
+ }
534
+ console.log(`[pltt] services contract check passed (${targets.size} consumed, ${decorators.length} provider decorator(s)).`)
535
+ }
536
+
306
537
  // ---- scaffold provider stub --------------------------------------------------
307
538
 
308
539
  async function runScaffold(argv, { cwd }) {
@@ -318,9 +549,10 @@ async function runScaffold(argv, { cwd }) {
318
549
  provides.namespace = namespace
319
550
  provides.services = provides.services || []
320
551
  // Ensure a default service slot.
321
- let svc = provides.services.find((s) => (s.id || "") === method) || null
552
+ const serviceId = safeIdent(method.split(".")[0] || method).toLowerCase().replace(/_/g, ".")
553
+ let svc = provides.services.find((s) => (s.id || "") === serviceId) || null
322
554
  if (!svc) {
323
- svc = { id: method, version: "v1", methods: [] }
555
+ svc = { id: serviceId, version: "v1", methods: [] }
324
556
  provides.services.push(svc)
325
557
  }
326
558
  svc.methods = svc.methods || []
@@ -410,11 +642,17 @@ async function run(argv, ctx) {
410
642
  const rest = argv.slice(1)
411
643
  if (sub === "list") return runList(rest, ctx)
412
644
  if (sub === "pull") return runPull(rest, ctx)
645
+ if (sub === "sync") return runSync(rest, ctx)
646
+ if (sub === "mock") return runMock(rest, ctx)
647
+ if (sub === "test") return runTest(rest, ctx)
413
648
  if (sub === "scaffold") return runScaffold(rest, ctx)
414
649
  if (sub === "add") return runAdd(rest, ctx)
415
650
  console.log("pltt services <command>\n")
416
651
  console.log(" list Show services/events available to the current org.")
417
652
  console.log(" pull Generate typed TS+Python clients from consumes block.")
653
+ console.log(" sync Update provides from backend @service decorators.")
654
+ console.log(" mock Add a local .palette/app-services.local.json response.")
655
+ console.log(" test Validate consumed targets, local mocks, and provider declarations.")
418
656
  console.log(" scaffold Add a new provider method + schemas + handler stub.")
419
657
  console.log(" add Add a consumed service/event target to palette-plugin.json.")
420
658
  if (sub && !["help", "--help", "-h"].includes(sub)) {
@@ -272,6 +272,18 @@ class LocalAppInteropService:
272
272
  return DEV_APP_MOCKS[key]
273
273
  raise RuntimeError("No local app service mock configured for " + key)
274
274
 
275
+ async def broker_call(self, target: str, payload=None):
276
+ payload = payload or {}
277
+ payload_key = target + " " + json.dumps(payload, sort_keys=True, separators=(",", ":"))
278
+ if payload_key in DEV_APP_MOCKS:
279
+ return DEV_APP_MOCKS[payload_key]
280
+ if target in DEV_APP_MOCKS:
281
+ return DEV_APP_MOCKS[target]
282
+ raise RuntimeError("No local broker mock configured for " + target)
283
+
284
+ async def broker_emit(self, target: str, payload=None):
285
+ print("[palette-broker-event]", target, json.dumps(payload or {}, sort_keys=True))
286
+
275
287
  class LocalAppServiceClient:
276
288
  def __init__(self, service_id: str):
277
289
  self.service_id = service_id
package/lib/manifest.js CHANGED
@@ -108,6 +108,12 @@ function requireString(obj, key, label, errors) {
108
108
  }
109
109
  }
110
110
 
111
+ function requireSchema(obj, key, label, errors) {
112
+ if (obj[key] !== undefined && typeof obj[key] !== "string" && !isObject(obj[key])) {
113
+ errors.push(`${label}.${key} must be a JSON schema object or schema file path`)
114
+ }
115
+ }
116
+
111
117
  function validateArray(value, label, errors) {
112
118
  if (value !== undefined && !Array.isArray(value)) errors.push(`${label} must be an array`)
113
119
  }
@@ -294,6 +300,8 @@ function validateServiceMethods(value, label, errors) {
294
300
  "output",
295
301
  "route_method",
296
302
  "route_path",
303
+ "deprecated",
304
+ "replacement",
297
305
  ]),
298
306
  methodLabel,
299
307
  errors,
@@ -304,9 +312,14 @@ function validateServiceMethods(value, label, errors) {
304
312
  errors.push(`duplicate provided method name: ${method.name}`)
305
313
  }
306
314
  seen.add(method.name)
307
- for (const key of ["scope", "label", "description", "input_schema", "input", "output_schema", "output", "route_path"]) {
315
+ for (const key of ["scope", "label", "description", "route_path"]) {
308
316
  requireString(method, key, methodLabel, errors)
309
317
  }
318
+ requireBoolean(method, "deprecated", methodLabel, errors)
319
+ requireString(method, "replacement", methodLabel, errors)
320
+ for (const key of ["input_schema", "input", "output_schema", "output"]) {
321
+ requireSchema(method, key, methodLabel, errors)
322
+ }
310
323
  if (method.route_method !== undefined && !methods.has(String(method.route_method).toUpperCase())) {
311
324
  errors.push(`${methodLabel}.route_method must be one of ${Array.from(methods).join(", ")}`)
312
325
  }
@@ -374,9 +387,12 @@ function validateProvides(value, errors) {
374
387
  } else if (isObject(event)) {
375
388
  unknownKeys(event, new Set(["topic", "name", "version", "payload_schema", "schema", "description"]), label, errors)
376
389
  topic = event.topic || event.name
377
- for (const key of ["version", "payload_schema", "schema", "description"]) {
390
+ for (const key of ["version", "description"]) {
378
391
  requireString(event, key, label, errors)
379
392
  }
393
+ for (const key of ["payload_schema", "schema"]) {
394
+ requireSchema(event, key, label, errors)
395
+ }
380
396
  } else {
381
397
  errors.push(`${label} must be a topic string or object`)
382
398
  return
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@palettelab/cli",
3
- "version": "0.3.51",
3
+ "version": "0.3.53",
4
4
  "description": "Developer CLI for building Palette platform plugins — no platform source access required.",
5
5
  "bin": {
6
6
  "pltt": "bin/pltt.js"
@@ -4,7 +4,7 @@
4
4
  "private": true,
5
5
  "description": "A Palette platform plugin",
6
6
  "dependencies": {
7
- "@palettelab/sdk": "^0.1.21"
7
+ "@palettelab/sdk": "^0.1.23"
8
8
  },
9
9
  "devDependencies": {
10
10
  "typescript": "^5.0.0",
@@ -0,0 +1,10 @@
1
+ # Consumer App Template
2
+
3
+ This template consumes `hr/v1#approvalChain.get`.
4
+
5
+ For local development without a provider app:
6
+
7
+ ```bash
8
+ pltt services mock hr/v1#approvalChain.get
9
+ pltt dev
10
+ ```
@@ -0,0 +1,15 @@
1
+ from fastapi import Depends
2
+ from palette_sdk import PluginContext, PluginRouter, get_plugin_context, services
3
+
4
+ router = PluginRouter()
5
+
6
+
7
+ @router.post("/leave-requests")
8
+ async def create_leave_request(payload: dict, ctx: PluginContext = Depends(get_plugin_context)):
9
+ chain = await services(ctx).call("hr/v1#approvalChain.get", {
10
+ "user_id": payload.get("user_id", ctx.user_id),
11
+ })
12
+ return {
13
+ "status": "submitted",
14
+ "approval_chain": chain,
15
+ }
@@ -0,0 +1,26 @@
1
+ import React, { useState } from "react"
2
+ import { createRoot } from "react-dom/client"
3
+ import { createPaletteClient, usePlatform } from "@palettelab/sdk"
4
+
5
+ function App() {
6
+ const platform = usePlatform()
7
+ const palette = createPaletteClient(platform)
8
+ const [result, setResult] = useState<string>("")
9
+
10
+ async function previewApprover() {
11
+ const chain = await palette.services("hr/v1").approvalChain.get({
12
+ user_id: platform.user.id,
13
+ })
14
+ setResult(JSON.stringify(chain, null, 2))
15
+ }
16
+
17
+ return (
18
+ <main style={{ padding: 24, fontFamily: "system-ui, sans-serif" }}>
19
+ <h1>Consumer App</h1>
20
+ <button onClick={previewApprover}>Preview approver</button>
21
+ {result && <pre>{result}</pre>}
22
+ </main>
23
+ )
24
+ }
25
+
26
+ createRoot(document.getElementById("root")!).render(<App />)
@@ -0,0 +1,9 @@
1
+ {
2
+ "private": true,
3
+ "type": "module",
4
+ "dependencies": {
5
+ "@palettelab/sdk": "^0.1.23",
6
+ "react": "^19.0.0",
7
+ "react-dom": "^19.0.0"
8
+ }
9
+ }
@@ -0,0 +1,45 @@
1
+ {
2
+ "manifest_version": "1",
3
+ "id": "consumer-app",
4
+ "name": "Consumer App",
5
+ "version": "1.0.0",
6
+ "developer": "Palette",
7
+ "category": "Productivity",
8
+ "tagline": "Consume governed services from other apps",
9
+ "description": "Example consumer app that asks an HR provider for approval routing.",
10
+ "icon": "ClipboardText",
11
+ "gradient": {
12
+ "bg": "linear-gradient(135deg, #7C3AED, #DB2777)",
13
+ "text": "#ffffff"
14
+ },
15
+ "capabilities": {
16
+ "frontend": true,
17
+ "backend": true
18
+ },
19
+ "frontend": {
20
+ "entry": "./frontend/src/index.tsx"
21
+ },
22
+ "backend": {
23
+ "entry": "./backend/api/main.py"
24
+ },
25
+ "permissions": [
26
+ "resources:read",
27
+ "resources:write"
28
+ ],
29
+ "consumes": {
30
+ "services": [
31
+ {
32
+ "target": "hr/v1#approvalChain.get",
33
+ "required": true,
34
+ "reason": "Leave approvals follow the employee reporting hierarchy."
35
+ }
36
+ ],
37
+ "events": [
38
+ {
39
+ "target": "hr/v1#hierarchy.updated",
40
+ "required": false,
41
+ "reason": "Refresh approval routing when hierarchy changes."
42
+ }
43
+ ]
44
+ }
45
+ }
@@ -0,0 +1,11 @@
1
+ [project]
2
+ name = "consumer-app"
3
+ version = "1.0.0"
4
+ requires-python = ">=3.12"
5
+ dependencies = [
6
+ "palette-sdk"
7
+ ]
8
+
9
+ [build-system]
10
+ requires = ["setuptools>=75.0"]
11
+ build-backend = "setuptools.build_meta"
@@ -3,7 +3,7 @@
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
5
  "dependencies": {
6
- "@palettelab/sdk": "^0.1.21",
6
+ "@palettelab/sdk": "^0.1.23",
7
7
  "react": "^19.0.0"
8
8
  }
9
9
  }
@@ -2,5 +2,5 @@
2
2
  "name": "my-db-plugin",
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
- "dependencies": { "@palettelab/sdk": "^0.1.21", "react": "^19.0.0" }
5
+ "dependencies": { "@palettelab/sdk": "^0.1.23", "react": "^19.0.0" }
6
6
  }
@@ -2,5 +2,5 @@
2
2
  "name": "my-external-svc",
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
- "dependencies": { "@palettelab/sdk": "^0.1.21", "react": "^19.0.0" }
5
+ "dependencies": { "@palettelab/sdk": "^0.1.23", "react": "^19.0.0" }
6
6
  }
@@ -3,7 +3,7 @@
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
5
  "dependencies": {
6
- "@palettelab/sdk": "^0.1.21",
6
+ "@palettelab/sdk": "^0.1.23",
7
7
  "react": "^19.0.0"
8
8
  }
9
9
  }
@@ -3,7 +3,7 @@
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
5
  "dependencies": {
6
- "@palettelab/sdk": "^0.1.21",
6
+ "@palettelab/sdk": "^0.1.23",
7
7
  "react": "^19.0.0"
8
8
  },
9
9
  "devDependencies": {
@@ -3,7 +3,7 @@
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
5
  "dependencies": {
6
- "@palettelab/sdk": "^0.1.21",
6
+ "@palettelab/sdk": "^0.1.23",
7
7
  "react": "^19.0.0"
8
8
  },
9
9
  "devDependencies": {
@@ -0,0 +1,10 @@
1
+ # Provider App Template
2
+
3
+ This template exposes `hr/v1#approvalChain.get` through Palette's OS broker.
4
+
5
+ Run:
6
+
7
+ ```bash
8
+ pltt services sync
9
+ pltt dev
10
+ ```
@@ -0,0 +1,25 @@
1
+ from fastapi import Depends
2
+ from palette_sdk import PluginContext, PluginRouter, get_plugin_context, service
3
+
4
+ router = PluginRouter()
5
+
6
+
7
+ @service(
8
+ "approvalChain.get",
9
+ namespace="hr",
10
+ version="v1",
11
+ input_schema="schemas/approvalChain.get.in.json",
12
+ output_schema="schemas/approvalChain.get.out.json",
13
+ )
14
+ async def approval_chain(ctx: PluginContext, payload: dict) -> dict:
15
+ user_id = payload["user_id"]
16
+ return {
17
+ "user_id": user_id,
18
+ "next_approver_id": "manager-dev",
19
+ "levels": ["manager-dev", "hr-dev"],
20
+ }
21
+
22
+
23
+ @router.get("/health")
24
+ async def health(ctx: PluginContext = Depends(get_plugin_context)):
25
+ return {"ok": True, "plugin_id": ctx.plugin_id}
@@ -0,0 +1,8 @@
1
+ {
2
+ "private": true,
3
+ "type": "module",
4
+ "dependencies": {
5
+ "@palettelab/sdk": "^0.1.23",
6
+ "react": "^19.0.0"
7
+ }
8
+ }
@@ -0,0 +1,49 @@
1
+ {
2
+ "manifest_version": "1",
3
+ "id": "provider-app",
4
+ "name": "Provider App",
5
+ "version": "1.0.0",
6
+ "developer": "Palette",
7
+ "category": "Productivity",
8
+ "tagline": "Expose governed services to other apps",
9
+ "description": "Example provider app exposing employee hierarchy through the OS broker.",
10
+ "icon": "UsersThree",
11
+ "gradient": {
12
+ "bg": "linear-gradient(135deg, #0F766E, #2563EB)",
13
+ "text": "#ffffff"
14
+ },
15
+ "capabilities": {
16
+ "backend": true
17
+ },
18
+ "backend": {
19
+ "entry": "./backend/api/main.py"
20
+ },
21
+ "permissions": [
22
+ "resources:read"
23
+ ],
24
+ "provides": {
25
+ "namespace": "hr",
26
+ "services": [
27
+ {
28
+ "id": "approval",
29
+ "version": "v1",
30
+ "label": "Approval hierarchy",
31
+ "description": "Resolve the next approver for an employee.",
32
+ "methods": [
33
+ {
34
+ "name": "approvalChain.get",
35
+ "input_schema": "schemas/approvalChain.get.in.json",
36
+ "output_schema": "schemas/approvalChain.get.out.json"
37
+ }
38
+ ]
39
+ }
40
+ ],
41
+ "events": [
42
+ {
43
+ "topic": "hierarchy.updated",
44
+ "version": "v1",
45
+ "schema": "schemas/hierarchy.updated.json"
46
+ }
47
+ ]
48
+ }
49
+ }
@@ -0,0 +1,11 @@
1
+ [project]
2
+ name = "provider-app"
3
+ version = "1.0.0"
4
+ requires-python = ">=3.12"
5
+ dependencies = [
6
+ "palette-sdk"
7
+ ]
8
+
9
+ [build-system]
10
+ requires = ["setuptools>=75.0"]
11
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,8 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "properties": {
5
+ "user_id": { "type": "string" }
6
+ },
7
+ "required": ["user_id"]
8
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "properties": {
5
+ "user_id": { "type": "string" },
6
+ "next_approver_id": { "type": "string" },
7
+ "levels": {
8
+ "type": "array",
9
+ "items": { "type": "string" }
10
+ }
11
+ },
12
+ "required": ["user_id", "next_approver_id", "levels"]
13
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "properties": {
5
+ "changed_user_id": { "type": "string" }
6
+ },
7
+ "required": ["changed_user_id"]
8
+ }