@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.
- package/README.md +45 -1
- package/backend-sdk/palette_sdk/__init__.py +4 -0
- package/backend-sdk/palette_sdk/events.py +16 -0
- package/backend-sdk/palette_sdk/manifest.py +2 -0
- package/backend-sdk/palette_sdk/services.py +26 -2
- package/docs/python-backend-sdk.md +64 -5
- package/lib/commands/init.js +1 -1
- package/lib/commands/services.js +255 -17
- package/lib/dev-simulator.js +12 -0
- package/lib/manifest.js +18 -2
- package/package.json +1 -1
- package/template-fallback/package.json +1 -1
- package/template-fallback/templates/consumer-app/README.md +10 -0
- package/template-fallback/templates/consumer-app/backend/api/main.py +15 -0
- package/template-fallback/templates/consumer-app/frontend/src/index.tsx +26 -0
- package/template-fallback/templates/consumer-app/package.json +9 -0
- package/template-fallback/templates/consumer-app/palette-plugin.json +45 -0
- package/template-fallback/templates/consumer-app/pyproject.toml +11 -0
- package/template-fallback/templates/dashboard/package.json +1 -1
- package/template-fallback/templates/database/package.json +1 -1
- package/template-fallback/templates/external-service/package.json +1 -1
- package/template-fallback/templates/frontend-only/package.json +1 -1
- package/template-fallback/templates/next/package.json +1 -1
- package/template-fallback/templates/palette-app/package.json +1 -1
- package/template-fallback/templates/provider-app/README.md +10 -0
- package/template-fallback/templates/provider-app/backend/api/main.py +25 -0
- package/template-fallback/templates/provider-app/package.json +8 -0
- package/template-fallback/templates/provider-app/palette-plugin.json +49 -0
- package/template-fallback/templates/provider-app/pyproject.toml +11 -0
- package/template-fallback/templates/provider-app/schemas/approvalChain.get.in.json +8 -0
- package/template-fallback/templates/provider-app/schemas/approvalChain.get.out.json +13 -0
- 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
|
-
- `
|
|
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
|
-
{
|
|
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": [
|
|
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
|
package/lib/commands/init.js
CHANGED
|
@@ -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
|
package/lib/commands/services.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
291
|
+
const tree = {}
|
|
244
292
|
for (const svc of services) {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
304
|
+
const tree = {}
|
|
255
305
|
for (const evt of events) {
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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:
|
|
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)) {
|
package/lib/dev-simulator.js
CHANGED
|
@@ -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", "
|
|
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", "
|
|
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
|
@@ -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,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,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,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,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
|
+
}
|