@palettelab/cli 0.3.29 → 0.3.31
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 +34 -3
- package/backend-sdk/palette_sdk/__init__.py +13 -2
- package/backend-sdk/palette_sdk/db/__init__.py +18 -5
- package/backend-sdk/palette_sdk/db/base.py +17 -0
- package/backend-sdk/palette_sdk/manifest.py +18 -1
- package/backend-sdk/palette_sdk/plugin_context.py +13 -0
- package/docs/python-backend-sdk.md +36 -10
- package/lib/cli.js +10 -0
- package/lib/commands/build.js +42 -6
- package/lib/commands/dev.js +2 -0
- package/lib/commands/doctor.js +20 -0
- package/lib/commands/init.js +18 -2
- package/lib/commands/publish.js +54 -0
- package/lib/commands/secrets.js +124 -0
- package/lib/commands/test.js +62 -1
- package/lib/dev-simulator.js +8 -1
- package/lib/environments.js +5 -0
- package/lib/manifest.js +93 -0
- package/lib/secrets.js +155 -0
- package/package.json +1 -1
- package/template-fallback/templates/database/backend/api/models.py +1 -1
- package/template-fallback/templates/database/backend/migrations/versions/001_init.py +5 -5
package/README.md
CHANGED
|
@@ -224,7 +224,8 @@ Declare database ownership in `palette-plugin.json`:
|
|
|
224
224
|
}
|
|
225
225
|
```
|
|
226
226
|
|
|
227
|
-
Define org-scoped models with the backend SDK
|
|
227
|
+
Define org-scoped models with the backend SDK. Table names must start with the
|
|
228
|
+
app prefix (`my_app__` for `my-app`):
|
|
228
229
|
|
|
229
230
|
```python
|
|
230
231
|
from sqlalchemy import String
|
|
@@ -232,7 +233,7 @@ from sqlalchemy.orm import Mapped, mapped_column
|
|
|
232
233
|
from palette_sdk import OrgScopedTable
|
|
233
234
|
|
|
234
235
|
class Invoice(OrgScopedTable):
|
|
235
|
-
__tablename__ = "
|
|
236
|
+
__tablename__ = "my_app__invoices"
|
|
236
237
|
|
|
237
238
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
238
239
|
customer_name: Mapped[str] = mapped_column(String(255))
|
|
@@ -245,7 +246,7 @@ from palette_sdk.db import ensure_org_rls
|
|
|
245
246
|
|
|
246
247
|
def upgrade():
|
|
247
248
|
op.create_table(...)
|
|
248
|
-
ensure_org_rls(op, "
|
|
249
|
+
ensure_org_rls(op, "my_app__invoices")
|
|
249
250
|
```
|
|
250
251
|
|
|
251
252
|
Use `ctx.repo(Model)` for org-safe CRUD:
|
|
@@ -440,6 +441,36 @@ Environment variables:
|
|
|
440
441
|
| `PALETTE_DEV_DATABASE_URL` | `.palette/dev/<plugin-id>.sqlite3` | Override the local dev database URL |
|
|
441
442
|
| `APPSTORE_AUTO_APPROVE_SANDBOX_PREVIEWS` | `false` | Backend setting for hosted sandboxes; auto-approve passing preview publishes so developers can test full OS behavior without manual review |
|
|
442
443
|
|
|
444
|
+
### `pltt secrets`
|
|
445
|
+
|
|
446
|
+
Palette secrets are declared in `palette-plugin.json` and resolved through
|
|
447
|
+
`ctx.secret("NAME")`.
|
|
448
|
+
|
|
449
|
+
```json
|
|
450
|
+
{
|
|
451
|
+
"secrets": {
|
|
452
|
+
"OPENAI_API_KEY": { "scope": "install", "required": true },
|
|
453
|
+
"STRIPE_SECRET": { "scope": "plugin", "required": true },
|
|
454
|
+
"DEBUG_PROBE_URL": { "scope": "dev", "required": false }
|
|
455
|
+
},
|
|
456
|
+
"platform_services": ["llm", "kv", "storage"]
|
|
457
|
+
}
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
Commands:
|
|
461
|
+
|
|
462
|
+
```bash
|
|
463
|
+
pltt secrets init
|
|
464
|
+
pltt secrets set STRIPE_SECRET --env staging --value sk_live_...
|
|
465
|
+
pltt secrets list --env staging
|
|
466
|
+
pltt publish --env staging --secrets-file plugin-secrets.env
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
`dev` secrets live in `.palette/.env.local`, are loaded by `pltt dev`, and are
|
|
470
|
+
never uploaded. `plugin` secrets are encrypted by the platform and attached to
|
|
471
|
+
the plugin/environment. `install` secrets are filled by the installing org.
|
|
472
|
+
Frontend bundles may only receive public values such as `NEXT_PUBLIC_*`.
|
|
473
|
+
|
|
443
474
|
### `pltt login`
|
|
444
475
|
|
|
445
476
|
Save a Palette sandbox or production environment URL plus token in `~/.palette/config.json` with file mode `0600`. Environment variables still override the stored token when present.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Palette Platform SDK for building backend plugins."""
|
|
2
2
|
|
|
3
3
|
from palette_sdk.plugin_router import PluginRouter
|
|
4
|
-
from palette_sdk.plugin_context import PluginContext, get_plugin_context
|
|
4
|
+
from palette_sdk.plugin_context import MissingSecretError, PluginContext, get_plugin_context
|
|
5
5
|
from palette_sdk.data_rooms import DataRoomsClient
|
|
6
6
|
from palette_sdk.repository import OrgRepository
|
|
7
7
|
from palette_sdk.lifecycle import LifecycleHooks
|
|
@@ -12,7 +12,14 @@ from palette_sdk.schemas import (
|
|
|
12
12
|
ErrorResponse,
|
|
13
13
|
PaginatedResponse,
|
|
14
14
|
)
|
|
15
|
-
from palette_sdk.db import
|
|
15
|
+
from palette_sdk.db import (
|
|
16
|
+
OrgScopedTable,
|
|
17
|
+
PluginBase,
|
|
18
|
+
ensure_org_rls,
|
|
19
|
+
plugin_safe_id,
|
|
20
|
+
plugin_schema,
|
|
21
|
+
plugin_table_prefix,
|
|
22
|
+
)
|
|
16
23
|
from palette_sdk.permissions import (
|
|
17
24
|
KNOWN_PERMISSIONS,
|
|
18
25
|
is_known_permission,
|
|
@@ -26,6 +33,7 @@ from palette_sdk.testing import route_permission_issues
|
|
|
26
33
|
__all__ = [
|
|
27
34
|
"PluginRouter",
|
|
28
35
|
"PluginContext",
|
|
36
|
+
"MissingSecretError",
|
|
29
37
|
"get_plugin_context",
|
|
30
38
|
"DataRoomsClient",
|
|
31
39
|
"OrgRepository",
|
|
@@ -39,6 +47,9 @@ __all__ = [
|
|
|
39
47
|
"OrgScopedTable",
|
|
40
48
|
"PluginBase",
|
|
41
49
|
"ensure_org_rls",
|
|
50
|
+
"plugin_safe_id",
|
|
51
|
+
"plugin_schema",
|
|
52
|
+
"plugin_table_prefix",
|
|
42
53
|
"KNOWN_PERMISSIONS",
|
|
43
54
|
"is_known_permission",
|
|
44
55
|
"require_permission",
|
|
@@ -12,7 +12,7 @@ Typical plugin usage:
|
|
|
12
12
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
13
13
|
|
|
14
14
|
class Expense(OrgScopedTable):
|
|
15
|
-
__tablename__ = "
|
|
15
|
+
__tablename__ = "my_app__expenses"
|
|
16
16
|
title: Mapped[str] = mapped_column(String(200))
|
|
17
17
|
amount_cents: Mapped[int]
|
|
18
18
|
|
|
@@ -23,16 +23,29 @@ Typical plugin usage:
|
|
|
23
23
|
|
|
24
24
|
def upgrade():
|
|
25
25
|
op.create_table(
|
|
26
|
-
"
|
|
26
|
+
"my_app__expenses",
|
|
27
27
|
sa.Column("id", sa.Integer, primary_key=True),
|
|
28
28
|
sa.Column("organization_id", sa.BigInteger, nullable=False, index=True),
|
|
29
29
|
sa.Column("title", sa.String(200), nullable=False),
|
|
30
30
|
sa.Column("amount_cents", sa.Integer, nullable=False),
|
|
31
31
|
)
|
|
32
|
-
ensure_org_rls(op, "
|
|
32
|
+
ensure_org_rls(op, "my_app__expenses")
|
|
33
33
|
"""
|
|
34
34
|
|
|
35
|
-
from palette_sdk.db.base import
|
|
35
|
+
from palette_sdk.db.base import (
|
|
36
|
+
OrgScopedTable,
|
|
37
|
+
PluginBase,
|
|
38
|
+
plugin_safe_id,
|
|
39
|
+
plugin_schema,
|
|
40
|
+
plugin_table_prefix,
|
|
41
|
+
)
|
|
36
42
|
from palette_sdk.db.rls import ensure_org_rls
|
|
37
43
|
|
|
38
|
-
__all__ = [
|
|
44
|
+
__all__ = [
|
|
45
|
+
"OrgScopedTable",
|
|
46
|
+
"PluginBase",
|
|
47
|
+
"ensure_org_rls",
|
|
48
|
+
"plugin_safe_id",
|
|
49
|
+
"plugin_schema",
|
|
50
|
+
"plugin_table_prefix",
|
|
51
|
+
]
|
|
@@ -11,6 +11,23 @@ from sqlalchemy import BigInteger, Index
|
|
|
11
11
|
from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column
|
|
12
12
|
|
|
13
13
|
|
|
14
|
+
def plugin_safe_id(plugin_id: str) -> str:
|
|
15
|
+
"""Return the DB-safe form of a plugin id."""
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
return re.sub(r"[^a-z0-9_]", "_", plugin_id.lower())
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def plugin_schema(plugin_id: str) -> str:
|
|
22
|
+
"""Return the Postgres schema name owned by a plugin."""
|
|
23
|
+
return f"app_{plugin_safe_id(plugin_id)}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def plugin_table_prefix(plugin_id: str) -> str:
|
|
27
|
+
"""Return the required table-name prefix for plugin-owned tables."""
|
|
28
|
+
return f"{plugin_safe_id(plugin_id)}__"
|
|
29
|
+
|
|
30
|
+
|
|
14
31
|
class PluginBase(DeclarativeBase):
|
|
15
32
|
"""Declarative base for plugin models.
|
|
16
33
|
|
|
@@ -6,7 +6,7 @@ import json
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
from typing import Literal
|
|
8
8
|
|
|
9
|
-
from pydantic import BaseModel, Field
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
class AgentDefinition(BaseModel):
|
|
@@ -73,6 +73,21 @@ class RateLimit(BaseModel):
|
|
|
73
73
|
per_minute: int = 60
|
|
74
74
|
|
|
75
75
|
|
|
76
|
+
class SecretSpec(BaseModel):
|
|
77
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
78
|
+
|
|
79
|
+
scope: Literal["dev", "plugin", "install", "platform"] | list[Literal["dev", "plugin", "install", "platform"]] = "dev"
|
|
80
|
+
required: bool = False
|
|
81
|
+
label: str | None = None
|
|
82
|
+
help: str | None = None
|
|
83
|
+
validate_pattern: str | None = Field(default=None, alias="validate")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class PlatformServiceSpec(BaseModel):
|
|
87
|
+
required: bool = False
|
|
88
|
+
billing: Literal["org_wallet", "plugin_owner", "platform"] | None = None
|
|
89
|
+
|
|
90
|
+
|
|
76
91
|
class PluginManifest(BaseModel):
|
|
77
92
|
"""Validated plugin manifest from palette-plugin.json."""
|
|
78
93
|
|
|
@@ -97,6 +112,8 @@ class PluginManifest(BaseModel):
|
|
|
97
112
|
agents: list[AgentDefinition] = Field(default_factory=list)
|
|
98
113
|
tools: list[ToolEntry] = Field(default_factory=list)
|
|
99
114
|
permissions: list[str] = Field(default_factory=list)
|
|
115
|
+
secrets: dict[str, SecretSpec] = Field(default_factory=dict)
|
|
116
|
+
platform_services: list[Literal["llm", "kv", "storage"]] | dict[str, PlatformServiceSpec] = Field(default_factory=list)
|
|
100
117
|
rating: float = 0.0
|
|
101
118
|
reviews: int = 0
|
|
102
119
|
featured: bool = False
|
|
@@ -13,6 +13,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
13
13
|
from palette_sdk.data_rooms import DataRoomsClient
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
class MissingSecretError(KeyError):
|
|
17
|
+
"""Raised when a declared required plugin secret is not configured."""
|
|
18
|
+
|
|
19
|
+
|
|
16
20
|
@dataclass
|
|
17
21
|
class PluginContext:
|
|
18
22
|
"""Context provided to plugin endpoints by the platform.
|
|
@@ -63,6 +67,15 @@ class PluginContext:
|
|
|
63
67
|
secrets = self.config.get("secrets")
|
|
64
68
|
if isinstance(secrets, dict) and key in secrets:
|
|
65
69
|
return secrets[key]
|
|
70
|
+
specs = self.config.get("secret_specs")
|
|
71
|
+
spec = specs.get(key) if isinstance(specs, dict) else None
|
|
72
|
+
if isinstance(spec, dict):
|
|
73
|
+
if default is not None:
|
|
74
|
+
return default
|
|
75
|
+
if spec.get("required"):
|
|
76
|
+
help_text = spec.get("help") or spec.get("label") or "Configure this secret before using the plugin."
|
|
77
|
+
raise MissingSecretError(f"missing plugin secret {key}: {help_text}")
|
|
78
|
+
return default
|
|
66
79
|
return os.environ.get(key, default)
|
|
67
80
|
|
|
68
81
|
def repo(self, model: type[Any]):
|
|
@@ -183,7 +183,7 @@ from palette_sdk import OrgScopedTable
|
|
|
183
183
|
|
|
184
184
|
|
|
185
185
|
class Invoice(OrgScopedTable):
|
|
186
|
-
__tablename__ = "
|
|
186
|
+
__tablename__ = "finance_tools__invoices"
|
|
187
187
|
|
|
188
188
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
189
189
|
customer_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
@@ -191,8 +191,10 @@ class Invoice(OrgScopedTable):
|
|
|
191
191
|
status: Mapped[str] = mapped_column(String(32), default="draft")
|
|
192
192
|
```
|
|
193
193
|
|
|
194
|
-
`OrgScopedTable` adds `organization_id` automatically.
|
|
195
|
-
|
|
194
|
+
`OrgScopedTable` adds `organization_id` automatically. Table names must start
|
|
195
|
+
with the app prefix (`finance_tools__` for `finance-tools`) so app-owned data is
|
|
196
|
+
easy to identify and validate. Do not manually set `organization_id` in route
|
|
197
|
+
code; `ctx.repo(Model)` handles it.
|
|
196
198
|
|
|
197
199
|
### Migration Example
|
|
198
200
|
|
|
@@ -210,22 +212,22 @@ down_revision = None
|
|
|
210
212
|
|
|
211
213
|
def upgrade():
|
|
212
214
|
op.create_table(
|
|
213
|
-
"
|
|
215
|
+
"finance_tools__invoices",
|
|
214
216
|
sa.Column("id", sa.Integer(), primary_key=True),
|
|
215
217
|
sa.Column("organization_id", sa.BigInteger(), nullable=False),
|
|
216
218
|
sa.Column("customer_name", sa.String(length=255), nullable=False),
|
|
217
219
|
sa.Column("amount", sa.Numeric(12, 2), nullable=False),
|
|
218
220
|
sa.Column("status", sa.String(length=32), nullable=False),
|
|
219
221
|
)
|
|
220
|
-
op.create_index("
|
|
221
|
-
ensure_org_rls(op, "
|
|
222
|
+
op.create_index("ix_finance_tools__invoices_org", "finance_tools__invoices", ["organization_id"])
|
|
223
|
+
ensure_org_rls(op, "finance_tools__invoices")
|
|
222
224
|
|
|
223
225
|
|
|
224
226
|
def downgrade():
|
|
225
|
-
op.drop_table("
|
|
227
|
+
op.drop_table("finance_tools__invoices")
|
|
226
228
|
```
|
|
227
229
|
|
|
228
|
-
`ensure_org_rls(op, "
|
|
230
|
+
`ensure_org_rls(op, "finance_tools__invoices")` enables row-level security so each
|
|
229
231
|
organization can only access its own rows.
|
|
230
232
|
|
|
231
233
|
## 7. Repository CRUD
|
|
@@ -476,8 +478,32 @@ async def sync_config(ctx: PluginContext = Depends(get_plugin_context)):
|
|
|
476
478
|
}
|
|
477
479
|
```
|
|
478
480
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
+
Declare secret ownership in `palette-plugin.json`:
|
|
482
|
+
|
|
483
|
+
```json
|
|
484
|
+
{
|
|
485
|
+
"secrets": {
|
|
486
|
+
"FINANCE_API_KEY": {
|
|
487
|
+
"scope": "install",
|
|
488
|
+
"required": true,
|
|
489
|
+
"help": "Provided by the installing organization."
|
|
490
|
+
},
|
|
491
|
+
"AUTHOR_STRIPE_SECRET": {
|
|
492
|
+
"scope": "plugin",
|
|
493
|
+
"required": true
|
|
494
|
+
},
|
|
495
|
+
"DEBUG_PROBE_URL": {
|
|
496
|
+
"scope": "dev",
|
|
497
|
+
"required": false
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
`ctx.secret("KEY")` resolves declared secrets from the configured scope:
|
|
504
|
+
install config, plugin-scope encrypted secrets, or local `.palette/.env.local`
|
|
505
|
+
during `pltt dev`. Undeclared keys still fall back to the process environment
|
|
506
|
+
for local compatibility.
|
|
481
507
|
|
|
482
508
|
## 10. Lifecycle Hooks
|
|
483
509
|
|
package/lib/cli.js
CHANGED
|
@@ -10,6 +10,7 @@ const login = require("./commands/login")
|
|
|
10
10
|
const pkg = require("./commands/package")
|
|
11
11
|
const status = require("./commands/status")
|
|
12
12
|
const logs = require("./commands/logs")
|
|
13
|
+
const secrets = require("./commands/secrets")
|
|
13
14
|
|
|
14
15
|
const COMMANDS = {
|
|
15
16
|
init: { run: init, help: "Scaffold a new plugin directory from the template" },
|
|
@@ -41,6 +42,10 @@ const COMMANDS = {
|
|
|
41
42
|
run: logs,
|
|
42
43
|
help: "Tail telemetry events for a plugin (--follow to stream)",
|
|
43
44
|
},
|
|
45
|
+
secrets: {
|
|
46
|
+
run: secrets,
|
|
47
|
+
help: "Initialize local env files and manage plugin-scope secrets",
|
|
48
|
+
},
|
|
44
49
|
}
|
|
45
50
|
|
|
46
51
|
function printHelp() {
|
|
@@ -68,6 +73,10 @@ function printHelp() {
|
|
|
68
73
|
console.log("\nLogs flags:")
|
|
69
74
|
console.log(" --tail <n> Tail last n events (default 50)")
|
|
70
75
|
console.log(" -f, --follow Stream events (poll every 3s)")
|
|
76
|
+
console.log("\nSecrets:")
|
|
77
|
+
console.log(" pltt secrets init")
|
|
78
|
+
console.log(" pltt secrets set NAME --value <secret> --env staging")
|
|
79
|
+
console.log(" pltt secrets list --env staging")
|
|
71
80
|
console.log("\nExamples:")
|
|
72
81
|
console.log(" pltt init my-app --template database")
|
|
73
82
|
console.log(" pltt login --env staging --url https://sandbox.pltt.ai --token <token>")
|
|
@@ -78,6 +87,7 @@ function printHelp() {
|
|
|
78
87
|
console.log(" pltt publish --env staging")
|
|
79
88
|
console.log(" pltt status")
|
|
80
89
|
console.log(" pltt logs --follow")
|
|
90
|
+
console.log(" pltt secrets init")
|
|
81
91
|
}
|
|
82
92
|
|
|
83
93
|
async function run(argv) {
|
package/lib/commands/build.js
CHANGED
|
@@ -19,9 +19,35 @@ const BANNED_PATTERNS = [
|
|
|
19
19
|
{ re: /\bDROP\s+SCHEMA\b/i, reason: "DROP SCHEMA is not allowed in plugin migrations" },
|
|
20
20
|
]
|
|
21
21
|
|
|
22
|
-
function
|
|
22
|
+
function pluginSafeId(pluginId) {
|
|
23
|
+
return String(pluginId || "").toLowerCase().replace(/[^a-z0-9_]/g, "_")
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function pluginSchema(pluginId) {
|
|
27
|
+
return `app_${pluginSafeId(pluginId)}`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function pluginTablePrefix(pluginId) {
|
|
31
|
+
return `${pluginSafeId(pluginId)}__`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function crossPluginSchemaRefs(src, allowedSchema) {
|
|
35
|
+
if (!allowedSchema) return []
|
|
36
|
+
const refs = new Set()
|
|
37
|
+
const re = /(^|[^a-z0-9_])"?((?:app)_[a-z0-9_]+)"?\s*\./gi
|
|
38
|
+
let match
|
|
39
|
+
while ((match = re.exec(src)) !== null) {
|
|
40
|
+
const schema = match[2].toLowerCase()
|
|
41
|
+
if (schema !== allowedSchema) refs.add(schema)
|
|
42
|
+
}
|
|
43
|
+
return [...refs].sort()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function lintMigrationFile(absPath, pluginId) {
|
|
23
47
|
const issues = []
|
|
24
48
|
const src = fs.readFileSync(absPath, "utf8")
|
|
49
|
+
const requiredPrefix = pluginId ? pluginTablePrefix(pluginId) : null
|
|
50
|
+
const allowedSchema = pluginId ? pluginSchema(pluginId) : null
|
|
25
51
|
|
|
26
52
|
for (const { re, reason } of BANNED_PATTERNS) {
|
|
27
53
|
if (re.test(src)) {
|
|
@@ -29,12 +55,16 @@ function lintMigrationFile(absPath) {
|
|
|
29
55
|
}
|
|
30
56
|
}
|
|
31
57
|
|
|
58
|
+
for (const schema of crossPluginSchemaRefs(src, allowedSchema)) {
|
|
59
|
+
issues.push(`${path.basename(absPath)}: plugin migrations must not reference another app schema (${schema})`)
|
|
60
|
+
}
|
|
61
|
+
|
|
32
62
|
// Every op.create_table("foo", ...) in the file must have a matching
|
|
33
63
|
// ensure_org_rls(op, "foo") somewhere in the same file. Caveat: this is a
|
|
34
64
|
// cheap syntactic check, not a full AST walk. If your table name is dynamic
|
|
35
65
|
// or your migration is unusual, you can silence the check by adding the
|
|
36
66
|
// magic comment `# palette:rls-ok` on the same logical migration.
|
|
37
|
-
|
|
67
|
+
const skipRlsCheck = /#\s*palette:rls-ok\b/.test(src)
|
|
38
68
|
|
|
39
69
|
const tableNames = new Set()
|
|
40
70
|
const createTableRe = /op\.create_table\(\s*['"]([a-zA-Z_][a-zA-Z0-9_]*)['"]/g
|
|
@@ -44,8 +74,13 @@ function lintMigrationFile(absPath) {
|
|
|
44
74
|
}
|
|
45
75
|
|
|
46
76
|
for (const name of tableNames) {
|
|
77
|
+
if (requiredPrefix && !name.startsWith(requiredPrefix)) {
|
|
78
|
+
issues.push(
|
|
79
|
+
`${path.basename(absPath)}: create_table("${name}") must use the app table prefix "${requiredPrefix}"`,
|
|
80
|
+
)
|
|
81
|
+
}
|
|
47
82
|
const rlsRe = new RegExp(`ensure_org_rls\\(\\s*op\\s*,\\s*['"]${name}['"]`)
|
|
48
|
-
if (!rlsRe.test(src)) {
|
|
83
|
+
if (!skipRlsCheck && !rlsRe.test(src)) {
|
|
49
84
|
issues.push(
|
|
50
85
|
`${path.basename(absPath)}: create_table("${name}") is missing ensure_org_rls(op, "${name}"). ` +
|
|
51
86
|
`Inherit from OrgScopedTable and call ensure_org_rls, or mark the migration with # palette:rls-ok if the table is intentionally global.`,
|
|
@@ -56,7 +91,7 @@ function lintMigrationFile(absPath) {
|
|
|
56
91
|
return issues
|
|
57
92
|
}
|
|
58
93
|
|
|
59
|
-
function lintMigrationsDir(migrationsDir) {
|
|
94
|
+
function lintMigrationsDir(migrationsDir, pluginId) {
|
|
60
95
|
const errors = []
|
|
61
96
|
const versionsDir = path.join(migrationsDir, "versions")
|
|
62
97
|
if (!fs.existsSync(versionsDir)) {
|
|
@@ -66,7 +101,7 @@ function lintMigrationsDir(migrationsDir) {
|
|
|
66
101
|
for (const entry of fs.readdirSync(versionsDir)) {
|
|
67
102
|
if (!entry.endsWith(".py")) continue
|
|
68
103
|
const abs = path.join(versionsDir, entry)
|
|
69
|
-
errors.push(...lintMigrationFile(abs))
|
|
104
|
+
errors.push(...lintMigrationFile(abs, pluginId))
|
|
70
105
|
}
|
|
71
106
|
return errors
|
|
72
107
|
}
|
|
@@ -92,7 +127,7 @@ async function run(args, { cwd }) {
|
|
|
92
127
|
} else if (!fs.existsSync(path.join(migrationsAbs, "env.py"))) {
|
|
93
128
|
errors.push(`database.migrations directory is missing env.py: ${migrationsRel}`)
|
|
94
129
|
} else {
|
|
95
|
-
errors.push(...lintMigrationsDir(migrationsAbs))
|
|
130
|
+
errors.push(...lintMigrationsDir(migrationsAbs, manifest.id))
|
|
96
131
|
}
|
|
97
132
|
}
|
|
98
133
|
|
|
@@ -107,3 +142,4 @@ async function run(args, { cwd }) {
|
|
|
107
142
|
module.exports = run
|
|
108
143
|
module.exports.lintMigrationsDir = lintMigrationsDir
|
|
109
144
|
module.exports.lintMigrationFile = lintMigrationFile
|
|
145
|
+
module.exports.pluginTablePrefix = pluginTablePrefix
|
package/lib/commands/dev.js
CHANGED
|
@@ -7,6 +7,7 @@ const { watchFrontend } = require("../bundler")
|
|
|
7
7
|
const { parseFlags } = require("../environments")
|
|
8
8
|
const { resolveDevPorts } = require("../ports")
|
|
9
9
|
const { startSimulator } = require("../dev-simulator")
|
|
10
|
+
const { loadLocalEnv } = require("../secrets")
|
|
10
11
|
const publish = require("./publish")
|
|
11
12
|
|
|
12
13
|
const DEFAULT_PLATFORM_IMAGE = "ghcr.io/palette-lab/platform-dev:latest"
|
|
@@ -78,6 +79,7 @@ async function run(args, { cwd }) {
|
|
|
78
79
|
const { flags, rest } = parseFlags(args)
|
|
79
80
|
const cloud = rest.includes("--cloud") || rest.includes("--sandbox")
|
|
80
81
|
const platform = rest.includes("--platform")
|
|
82
|
+
loadLocalEnv(cwd)
|
|
81
83
|
if (cloud) {
|
|
82
84
|
const json = args.includes("--json")
|
|
83
85
|
const publishArgs = []
|
package/lib/commands/doctor.js
CHANGED
|
@@ -6,6 +6,7 @@ const { spawnSync } = require("child_process")
|
|
|
6
6
|
const { loadManifest, validateManifest } = require("../manifest")
|
|
7
7
|
const { bundleFrontend } = require("../bundler")
|
|
8
8
|
const { resolveDevPorts } = require("../ports")
|
|
9
|
+
const { declaredSecrets, loadLocalEnv } = require("../secrets")
|
|
9
10
|
|
|
10
11
|
const DEFAULT_IMAGE =
|
|
11
12
|
process.env.PALETTE_DEV_IMAGE || "ghcr.io/palette-lab/platform-dev:latest"
|
|
@@ -120,6 +121,25 @@ async function run(args, { cwd }) {
|
|
|
120
121
|
failures += checkEntry(cwd, `tool[${tool.name}]`, tool.entry)
|
|
121
122
|
}
|
|
122
123
|
|
|
124
|
+
const secrets = declaredSecrets(manifest)
|
|
125
|
+
const localSecrets = loadLocalEnv(cwd, { apply: false })
|
|
126
|
+
if (Object.keys(secrets).length === 0) {
|
|
127
|
+
ok("no manifest secrets declared")
|
|
128
|
+
} else {
|
|
129
|
+
ok(`manifest declares ${Object.keys(secrets).length} secret(s)`)
|
|
130
|
+
for (const [name, spec] of Object.entries(secrets)) {
|
|
131
|
+
if (spec.scope.includes("dev") && !localSecrets[name]) {
|
|
132
|
+
warn(`local dev secret missing: ${name}`, "Run pltt secrets init and fill .palette/.env.local.")
|
|
133
|
+
}
|
|
134
|
+
if (spec.scope.includes("plugin") && spec.required && !localSecrets[name] && !process.env[name]) {
|
|
135
|
+
warn(
|
|
136
|
+
`plugin secret missing locally: ${name}`,
|
|
137
|
+
"Set an env var, pass --secrets-file to publish, or run pltt secrets set after first publish.",
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
123
143
|
if (manifest.frontend?.entry) {
|
|
124
144
|
try {
|
|
125
145
|
const bundle = await bundleFrontend(cwd, manifest.frontend.entry, manifest.frontend)
|
package/lib/commands/init.js
CHANGED
|
@@ -4,6 +4,7 @@ const fs = require("fs")
|
|
|
4
4
|
const path = require("path")
|
|
5
5
|
const { spawnSync } = require("child_process")
|
|
6
6
|
const os = require("os")
|
|
7
|
+
const { initLocalEnv } = require("../secrets")
|
|
7
8
|
|
|
8
9
|
const DEFAULT_TEMPLATE_REPO = "palette-lab/plugin-template"
|
|
9
10
|
const TEMPLATE_REPO = process.env.PALETTE_TEMPLATE_REPO || DEFAULT_TEMPLATE_REPO
|
|
@@ -19,6 +20,10 @@ function toSlug(name) {
|
|
|
19
20
|
.replace(/^-|-$/g, "")
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
function toDbSafeId(pluginId) {
|
|
24
|
+
return pluginId.toLowerCase().replace(/[^a-z0-9_]/g, "_")
|
|
25
|
+
}
|
|
26
|
+
|
|
22
27
|
function fetchTemplate(destDir) {
|
|
23
28
|
const git = spawnSync(
|
|
24
29
|
"git",
|
|
@@ -69,11 +74,16 @@ function rewriteScaffold(destDir, slug, displayName) {
|
|
|
69
74
|
if (!fs.existsSync(manifestPath)) return
|
|
70
75
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"))
|
|
71
76
|
const originalId = manifest.id
|
|
77
|
+
const originalDbSafeId = originalId ? toDbSafeId(originalId) : null
|
|
78
|
+
const nextDbSafeId = toDbSafeId(slug)
|
|
72
79
|
if (originalId && originalId !== slug) {
|
|
73
80
|
walkFiles(destDir, (filePath) => {
|
|
74
81
|
try {
|
|
75
82
|
const before = fs.readFileSync(filePath, "utf8")
|
|
76
|
-
|
|
83
|
+
let after = before.split(originalId).join(slug)
|
|
84
|
+
if (originalDbSafeId && originalDbSafeId !== nextDbSafeId) {
|
|
85
|
+
after = after.split(originalDbSafeId).join(nextDbSafeId)
|
|
86
|
+
}
|
|
77
87
|
if (after !== before) fs.writeFileSync(filePath, after)
|
|
78
88
|
} catch (_err) {
|
|
79
89
|
// Ignore non-text files. Templates are expected to be text, but this
|
|
@@ -85,7 +95,7 @@ function rewriteScaffold(destDir, slug, displayName) {
|
|
|
85
95
|
rewrittenManifest.id = slug
|
|
86
96
|
rewrittenManifest.name = displayName
|
|
87
97
|
if (rewrittenManifest.database?.schema) {
|
|
88
|
-
rewrittenManifest.database.schema = `app_${
|
|
98
|
+
rewrittenManifest.database.schema = `app_${nextDbSafeId}`
|
|
89
99
|
}
|
|
90
100
|
fs.writeFileSync(manifestPath, JSON.stringify(rewrittenManifest, null, 2) + "\n")
|
|
91
101
|
}
|
|
@@ -156,6 +166,12 @@ async function run(args, { cwd }) {
|
|
|
156
166
|
fs.cpSync(tmp, destDir, { recursive: true })
|
|
157
167
|
fs.rmSync(tmp, { recursive: true, force: true })
|
|
158
168
|
rewriteScaffold(destDir, slug, displayName)
|
|
169
|
+
try {
|
|
170
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(destDir, "palette-plugin.json"), "utf8"))
|
|
171
|
+
initLocalEnv(destDir, manifest)
|
|
172
|
+
} catch (_err) {
|
|
173
|
+
// The scaffold is still usable; `pltt secrets init` can repair env files.
|
|
174
|
+
}
|
|
159
175
|
|
|
160
176
|
console.log(`[pltt] created ${destDir}`)
|
|
161
177
|
console.log("[pltt] next steps:")
|
package/lib/commands/publish.js
CHANGED
|
@@ -11,6 +11,13 @@ const {
|
|
|
11
11
|
parseFlags,
|
|
12
12
|
confirmProduction,
|
|
13
13
|
} = require("../environments")
|
|
14
|
+
const {
|
|
15
|
+
declaredSecrets,
|
|
16
|
+
loadLocalEnv,
|
|
17
|
+
parseDotEnv,
|
|
18
|
+
readDotEnvFile,
|
|
19
|
+
redactValue,
|
|
20
|
+
} = require("../secrets")
|
|
14
21
|
|
|
15
22
|
function sha256(buf) {
|
|
16
23
|
return crypto.createHash("sha256").update(buf).digest("hex")
|
|
@@ -77,6 +84,43 @@ async function put(url, buf, contentType) {
|
|
|
77
84
|
}
|
|
78
85
|
}
|
|
79
86
|
|
|
87
|
+
function collectPluginSecrets(cwd, manifest, env, flags, log) {
|
|
88
|
+
const declared = declaredSecrets(manifest)
|
|
89
|
+
const pluginSecrets = Object.entries(declared).filter(([, spec]) => spec.scope.includes("plugin"))
|
|
90
|
+
const devRequired = Object.entries(declared).filter(([, spec]) => spec.scope.includes("dev") && spec.required)
|
|
91
|
+
if (devRequired.length) {
|
|
92
|
+
log(
|
|
93
|
+
`[pltt] dev-only secrets are not uploaded: ${devRequired.map(([name]) => name).join(", ")}`,
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let fileValues = {}
|
|
98
|
+
if (flags.secretsFile) {
|
|
99
|
+
fileValues = parseDotEnv(fs.readFileSync(path.resolve(cwd, flags.secretsFile), "utf8"))
|
|
100
|
+
}
|
|
101
|
+
const localValues = readDotEnvFile(path.join(cwd, ".palette", ".env.local"))
|
|
102
|
+
const values = {}
|
|
103
|
+
const missing = []
|
|
104
|
+
for (const [name, spec] of pluginSecrets) {
|
|
105
|
+
const value = fileValues[name] ?? process.env[name] ?? localValues[name]
|
|
106
|
+
if (value) {
|
|
107
|
+
values[name] = value
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
if (spec.required) missing.push(name)
|
|
111
|
+
}
|
|
112
|
+
if (missing.length) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`missing required plugin-scope secret(s): ${missing.join(", ")}. ` +
|
|
115
|
+
`Set env vars, pass --secrets-file, or run pltt secrets set <NAME> --env ${env.name}.`,
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
for (const [name, value] of Object.entries(values)) {
|
|
119
|
+
log(`[pltt] plugin secret ${name}=${redactValue(value)} (${env.name})`)
|
|
120
|
+
}
|
|
121
|
+
return values
|
|
122
|
+
}
|
|
123
|
+
|
|
80
124
|
async function run(argv, { cwd }) {
|
|
81
125
|
const { flags } = parseFlags(argv)
|
|
82
126
|
const log = (...args) => {
|
|
@@ -109,6 +153,7 @@ async function run(argv, { cwd }) {
|
|
|
109
153
|
}
|
|
110
154
|
|
|
111
155
|
const manifest = loadManifest(cwd)
|
|
156
|
+
loadLocalEnv(cwd)
|
|
112
157
|
const errors = validateManifest(manifest)
|
|
113
158
|
if (errors.length) {
|
|
114
159
|
console.error("[pltt] manifest invalid:")
|
|
@@ -137,6 +182,13 @@ async function run(argv, { cwd }) {
|
|
|
137
182
|
|
|
138
183
|
const backendSha = sha256(backend)
|
|
139
184
|
const api = makeApi(env)
|
|
185
|
+
let pluginSecrets = {}
|
|
186
|
+
try {
|
|
187
|
+
pluginSecrets = collectPluginSecrets(cwd, manifest, env, flags, log)
|
|
188
|
+
} catch (err) {
|
|
189
|
+
console.error(`[pltt] ${err instanceof Error ? err.message : String(err)}`)
|
|
190
|
+
process.exit(1)
|
|
191
|
+
}
|
|
140
192
|
|
|
141
193
|
log("[pltt] requesting signed URLs")
|
|
142
194
|
const signed = await api("/api/v1/appstore/sign-upload", {
|
|
@@ -169,7 +221,9 @@ async function run(argv, { cwd }) {
|
|
|
169
221
|
bundle_path: signed.bundle_path,
|
|
170
222
|
bundle_sha256: backendSha,
|
|
171
223
|
manifest,
|
|
224
|
+
environment: env.name,
|
|
172
225
|
}
|
|
226
|
+
if (Object.keys(pluginSecrets).length) publishBody.plugin_secrets = pluginSecrets
|
|
173
227
|
if (Number.isFinite(flags.ttlHours) && flags.ttlHours > 0) {
|
|
174
228
|
publishBody.preview_ttl_hours = flags.ttlHours
|
|
175
229
|
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
const fs = require("fs")
|
|
4
|
+
const path = require("path")
|
|
5
|
+
const { loadManifest } = require("../manifest")
|
|
6
|
+
const { parseFlags, resolveEnvironment } = require("../environments")
|
|
7
|
+
const {
|
|
8
|
+
declaredSecrets,
|
|
9
|
+
initLocalEnv,
|
|
10
|
+
parseDotEnv,
|
|
11
|
+
readDotEnvFile,
|
|
12
|
+
redactValue,
|
|
13
|
+
} = require("../secrets")
|
|
14
|
+
|
|
15
|
+
function parseOwnFlags(argv) {
|
|
16
|
+
const out = { value: undefined, secretsFile: undefined }
|
|
17
|
+
const rest = []
|
|
18
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
19
|
+
const arg = argv[i]
|
|
20
|
+
if (arg === "--value") out.value = argv[++i]
|
|
21
|
+
else if (arg.startsWith("--value=")) out.value = arg.slice("--value=".length)
|
|
22
|
+
else if (arg === "--secrets-file") out.secretsFile = argv[++i]
|
|
23
|
+
else if (arg.startsWith("--secrets-file=")) out.secretsFile = arg.slice("--secrets-file=".length)
|
|
24
|
+
else rest.push(arg)
|
|
25
|
+
}
|
|
26
|
+
return { own: out, rest }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function makeApi(env) {
|
|
30
|
+
return async function api(pathname, { method = "GET", body } = {}) {
|
|
31
|
+
const res = await fetch(`${env.url}${pathname}`, {
|
|
32
|
+
method,
|
|
33
|
+
headers: {
|
|
34
|
+
Authorization: `Bearer ${env.token}`,
|
|
35
|
+
"Content-Type": "application/json",
|
|
36
|
+
},
|
|
37
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
38
|
+
})
|
|
39
|
+
if (!res.ok) throw new Error(`${method} ${pathname} -> ${res.status}: ${await res.text()}`)
|
|
40
|
+
const ct = res.headers.get("content-type") || ""
|
|
41
|
+
return ct.includes("application/json") ? res.json() : res.text()
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function loadFileValues(cwd, file) {
|
|
46
|
+
if (!file) return {}
|
|
47
|
+
const abs = path.resolve(cwd, file)
|
|
48
|
+
return parseDotEnv(fs.readFileSync(abs, "utf8"))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function run(args, { cwd }) {
|
|
52
|
+
const [subcommand, ...tail] = args
|
|
53
|
+
const manifest = loadManifest(cwd)
|
|
54
|
+
const declared = declaredSecrets(manifest)
|
|
55
|
+
|
|
56
|
+
if (!subcommand || subcommand === "help" || subcommand === "--help") {
|
|
57
|
+
console.log("Usage: pltt secrets <init|list|set|rotate> [NAME] [--env staging]")
|
|
58
|
+
console.log(" pltt secrets set NAME --value <secret> --env staging")
|
|
59
|
+
console.log(" pltt secrets set NAME --secrets-file plugin-secrets.env --env staging")
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (subcommand === "init") {
|
|
64
|
+
const result = initLocalEnv(cwd, manifest)
|
|
65
|
+
console.log(`[pltt] wrote ${path.relative(cwd, result.localPath)}`)
|
|
66
|
+
console.log(`[pltt] wrote ${path.relative(cwd, result.examplePath)}`)
|
|
67
|
+
console.log("[pltt] updated .gitignore for local env files")
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const { own, rest } = parseOwnFlags(tail)
|
|
72
|
+
const { flags } = parseFlags(rest)
|
|
73
|
+
let env
|
|
74
|
+
try {
|
|
75
|
+
env = resolveEnvironment({ cwd, flags })
|
|
76
|
+
} catch (err) {
|
|
77
|
+
console.error(`[pltt] ${err.message}`)
|
|
78
|
+
process.exit(1)
|
|
79
|
+
}
|
|
80
|
+
if (!env.token) {
|
|
81
|
+
console.error(`[pltt] no publish token for "${env.name}". Set $${env.token_env}.`)
|
|
82
|
+
process.exit(1)
|
|
83
|
+
}
|
|
84
|
+
const api = makeApi(env)
|
|
85
|
+
|
|
86
|
+
if (subcommand === "list") {
|
|
87
|
+
const response = await api(`/api/v1/appstore/plugins/${encodeURIComponent(manifest.id)}/secrets?env=${encodeURIComponent(env.name)}`)
|
|
88
|
+
for (const item of response.secrets || []) {
|
|
89
|
+
console.log(`${item.key}\tconfigured=${item.configured ? "yes" : "no"}\tupdated=${item.updated_at || "-"}`)
|
|
90
|
+
}
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (subcommand !== "set" && subcommand !== "rotate") {
|
|
95
|
+
console.error(`[pltt] unknown secrets command: ${subcommand}`)
|
|
96
|
+
process.exit(1)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const name = rest.find((item) => !item.startsWith("-"))
|
|
100
|
+
if (!name) {
|
|
101
|
+
console.error("[pltt] usage: pltt secrets set NAME --value <secret> [--env staging]")
|
|
102
|
+
process.exit(1)
|
|
103
|
+
}
|
|
104
|
+
const spec = declared[name]
|
|
105
|
+
if (spec && !spec.scope.includes("plugin")) {
|
|
106
|
+
console.error(`[pltt] ${name} is declared with scope ${spec.scope.join(",")}; only plugin-scope secrets are managed by this command.`)
|
|
107
|
+
process.exit(1)
|
|
108
|
+
}
|
|
109
|
+
const fileValues = loadFileValues(cwd, own.secretsFile)
|
|
110
|
+
const localValues = readDotEnvFile(path.join(cwd, ".palette", ".env.local"))
|
|
111
|
+
const value = own.value ?? fileValues[name] ?? process.env[name] ?? localValues[name]
|
|
112
|
+
if (!value) {
|
|
113
|
+
console.error(`[pltt] no value for ${name}. Pass --value, --secrets-file, or set $${name}.`)
|
|
114
|
+
process.exit(1)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
await api(`/api/v1/appstore/plugins/${encodeURIComponent(manifest.id)}/secrets/${encodeURIComponent(name)}`, {
|
|
118
|
+
method: "PUT",
|
|
119
|
+
body: { value, environment: env.name },
|
|
120
|
+
})
|
|
121
|
+
console.log(`[pltt] ${subcommand === "rotate" ? "rotated" : "set"} ${name}=${redactValue(value)} for ${manifest.id} (${env.name})`)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = run
|
package/lib/commands/test.js
CHANGED
|
@@ -5,6 +5,7 @@ const path = require("path")
|
|
|
5
5
|
const { spawnSync } = require("child_process")
|
|
6
6
|
const { loadManifest, validateManifest, KNOWN_PERMISSIONS } = require("../manifest")
|
|
7
7
|
const { bundleFrontend, bundleBackend } = require("../bundler")
|
|
8
|
+
const { declaredSecrets, loadLocalEnv } = require("../secrets")
|
|
8
9
|
const buildCommand = require("./build")
|
|
9
10
|
|
|
10
11
|
const DEFAULT_FRONTEND_BUNDLE_LIMIT = 512 * 1024
|
|
@@ -252,7 +253,7 @@ function lintMigrations(cwd, manifest, out) {
|
|
|
252
253
|
if (!fs.existsSync(path.join(migrationsAbs, "env.py"))) {
|
|
253
254
|
return out.fail(`database.migrations directory is missing env.py: ${migrationsRel}`)
|
|
254
255
|
}
|
|
255
|
-
const errors = buildCommand.lintMigrationsDir(migrationsAbs)
|
|
256
|
+
const errors = buildCommand.lintMigrationsDir(migrationsAbs, manifest.id)
|
|
256
257
|
for (const err of errors) out.fail(err)
|
|
257
258
|
if (errors.length === 0) out.ok("migration lint passed")
|
|
258
259
|
return errors.length
|
|
@@ -601,6 +602,64 @@ function sandboxBridgeSmoke(cwd, manifest, out) {
|
|
|
601
602
|
return 0
|
|
602
603
|
}
|
|
603
604
|
|
|
605
|
+
function checkDeclaredSecrets(cwd, manifest, out) {
|
|
606
|
+
const declared = declaredSecrets(manifest)
|
|
607
|
+
const names = Object.keys(declared)
|
|
608
|
+
if (names.length === 0) {
|
|
609
|
+
out.ok("no manifest secrets declared")
|
|
610
|
+
return 0
|
|
611
|
+
}
|
|
612
|
+
const localValues = loadLocalEnv(cwd, { apply: false })
|
|
613
|
+
let failures = 0
|
|
614
|
+
for (const [name, spec] of Object.entries(declared)) {
|
|
615
|
+
if (spec.scope.includes("dev") && spec.required && !localValues[name]) {
|
|
616
|
+
out.warn(
|
|
617
|
+
`required dev secret ${name} is missing from .palette/.env.local`,
|
|
618
|
+
"Run pltt secrets init and fill the local value before pltt dev.",
|
|
619
|
+
{ secret: name, scope: spec.scope },
|
|
620
|
+
)
|
|
621
|
+
}
|
|
622
|
+
if (spec.scope.includes("plugin") && spec.required && !localValues[name] && !process.env[name]) {
|
|
623
|
+
out.warn(
|
|
624
|
+
`required plugin secret ${name} has no local value`,
|
|
625
|
+
"Set it before publish with an env var, --secrets-file, or pltt secrets set.",
|
|
626
|
+
{ secret: name, scope: spec.scope },
|
|
627
|
+
)
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
if (!fs.existsSync(path.join(cwd, ".palette", ".env.local"))) {
|
|
631
|
+
out.warn(
|
|
632
|
+
".palette/.env.local is missing",
|
|
633
|
+
"Run pltt secrets init to create local developer env files.",
|
|
634
|
+
)
|
|
635
|
+
} else {
|
|
636
|
+
out.ok(".palette/.env.local is present")
|
|
637
|
+
}
|
|
638
|
+
out.ok(`manifest declares ${names.length} secret(s)`, { secrets: names })
|
|
639
|
+
return failures
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function checkFrontendSecretLeaks(cwd, frontendBuffer, manifest, out) {
|
|
643
|
+
const localValues = loadLocalEnv(cwd, { apply: false })
|
|
644
|
+
const declared = declaredSecrets(manifest)
|
|
645
|
+
let failures = 0
|
|
646
|
+
const bundleText = frontendBuffer.toString("utf8")
|
|
647
|
+
for (const [name, value] of Object.entries(localValues)) {
|
|
648
|
+
if (!value || String(value).length < 8) continue
|
|
649
|
+
const spec = declared[name]
|
|
650
|
+
const publicAllowed = name.startsWith("NEXT_PUBLIC_")
|
|
651
|
+
if (!publicAllowed && bundleText.includes(String(value))) {
|
|
652
|
+
failures += out.fail(
|
|
653
|
+
`frontend bundle contains local secret value for ${name}`,
|
|
654
|
+
"Move this value to backend ctx.secret(...) or rename it NEXT_PUBLIC_* only if it is intentionally public.",
|
|
655
|
+
{ secret: name, declared_scope: spec?.scope },
|
|
656
|
+
)
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
if (failures === 0) out.ok("frontend local-secret leak scan passed")
|
|
660
|
+
return failures
|
|
661
|
+
}
|
|
662
|
+
|
|
604
663
|
async function run(args, { cwd }) {
|
|
605
664
|
const json = args.includes("--json")
|
|
606
665
|
let failures = 0
|
|
@@ -630,6 +689,7 @@ async function run(args, { cwd }) {
|
|
|
630
689
|
failures += checkSdkCompatibility(cwd, manifest, out)
|
|
631
690
|
failures += scanForbiddenImports(cwd, manifest, out)
|
|
632
691
|
failures += checkSemverBump(cwd, manifest, out)
|
|
692
|
+
failures += checkDeclaredSecrets(cwd, manifest, out)
|
|
633
693
|
|
|
634
694
|
for (const permission of manifest.permissions || []) {
|
|
635
695
|
if (!KNOWN_PERMISSIONS.has(permission)) {
|
|
@@ -662,6 +722,7 @@ async function run(args, { cwd }) {
|
|
|
662
722
|
const frontend = await bundleFrontend(cwd, manifest.frontend.entry, manifest.frontend)
|
|
663
723
|
out.ok(`frontend bundles successfully (${frontend.length} bytes)`, { bytes: frontend.length })
|
|
664
724
|
failures += checkBundleSize("frontend", frontend.length, out)
|
|
725
|
+
failures += checkFrontendSecretLeaks(cwd, frontend, manifest, out)
|
|
665
726
|
failures += sandboxBridgeSmoke(cwd, manifest, out)
|
|
666
727
|
} catch (err) {
|
|
667
728
|
failures += out.fail(
|
package/lib/dev-simulator.js
CHANGED
|
@@ -7,6 +7,7 @@ const { spawn, spawnSync } = require("child_process")
|
|
|
7
7
|
|
|
8
8
|
const { loadManifest } = require("./manifest")
|
|
9
9
|
const { frontendBuildConfig } = require("./bundler")
|
|
10
|
+
const { loadLocalEnv } = require("./secrets")
|
|
10
11
|
|
|
11
12
|
function loadEsbuild() {
|
|
12
13
|
try {
|
|
@@ -95,6 +96,7 @@ function writeBackendRunner(cwd, devDir, manifest, backendEntry) {
|
|
|
95
96
|
const runner = path.join(devDir, "backend_runner.py")
|
|
96
97
|
const sdkPath = localBackendSdkPath()
|
|
97
98
|
const databasePath = path.join(devDir, `${manifest.id}.sqlite3`)
|
|
99
|
+
const devSecrets = loadLocalEnv(cwd, { apply: false })
|
|
98
100
|
const content = `from __future__ import annotations
|
|
99
101
|
|
|
100
102
|
import importlib
|
|
@@ -115,6 +117,7 @@ MANIFEST = json.loads(${JSON.stringify(JSON.stringify(manifest))})
|
|
|
115
117
|
SDK_PATH = ${JSON.stringify(sdkPath || "")}
|
|
116
118
|
DATABASE_ENABLED = bool(MANIFEST.get("database") or MANIFEST.get("capabilities", {}).get("database"))
|
|
117
119
|
DATABASE_URL = os.environ.get("PALETTE_DEV_DATABASE_URL", "sqlite+aiosqlite:///${databasePath.replace(/\\/g, "/")}")
|
|
120
|
+
DEV_SECRETS = json.loads(${JSON.stringify(JSON.stringify(devSecrets))})
|
|
118
121
|
|
|
119
122
|
if SDK_PATH:
|
|
120
123
|
sys.path.insert(0, SDK_PATH)
|
|
@@ -153,7 +156,11 @@ class DevPluginContextMiddleware(BaseHTTPMiddleware):
|
|
|
153
156
|
request.state.org_role = "owner"
|
|
154
157
|
request.state.plugin_id = MANIFEST.get("id", "")
|
|
155
158
|
request.state.plugin_permissions = MANIFEST.get("permissions", [])
|
|
156
|
-
request.state.plugin_config = {
|
|
159
|
+
request.state.plugin_config = {
|
|
160
|
+
"secrets": DEV_SECRETS,
|
|
161
|
+
"secret_specs": MANIFEST.get("secrets") or {},
|
|
162
|
+
"secret_scope": "dev",
|
|
163
|
+
}
|
|
157
164
|
request.state.storage = None
|
|
158
165
|
if SessionLocal is None:
|
|
159
166
|
request.state.db = None
|
package/lib/environments.js
CHANGED
|
@@ -173,6 +173,7 @@ function parseFlags(argv) {
|
|
|
173
173
|
url: undefined,
|
|
174
174
|
token: undefined,
|
|
175
175
|
default: true,
|
|
176
|
+
secretsFile: undefined,
|
|
176
177
|
}
|
|
177
178
|
const rest = []
|
|
178
179
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -201,6 +202,10 @@ function parseFlags(argv) {
|
|
|
201
202
|
flags.token = a.slice("--token=".length)
|
|
202
203
|
} else if (a === "--no-default") {
|
|
203
204
|
flags.default = false
|
|
205
|
+
} else if (a === "--secrets-file") {
|
|
206
|
+
flags.secretsFile = argv[++i]
|
|
207
|
+
} else if (a.startsWith("--secrets-file=")) {
|
|
208
|
+
flags.secretsFile = a.slice("--secrets-file=".length)
|
|
204
209
|
} else {
|
|
205
210
|
rest.push(a)
|
|
206
211
|
}
|
package/lib/manifest.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require("fs")
|
|
4
4
|
const path = require("path")
|
|
5
|
+
const { SECRET_SCOPES } = require("./secrets")
|
|
5
6
|
|
|
6
7
|
const MANIFEST_FILE = "palette-plugin.json"
|
|
7
8
|
const SUPPORTED_MANIFEST_VERSIONS = ["1"]
|
|
@@ -46,6 +47,8 @@ const TOP_LEVEL_KEYS = new Set([
|
|
|
46
47
|
"rate_limit",
|
|
47
48
|
"database",
|
|
48
49
|
"scheduled_jobs",
|
|
50
|
+
"secrets",
|
|
51
|
+
"platform_services",
|
|
49
52
|
])
|
|
50
53
|
|
|
51
54
|
function loadManifest(cwd) {
|
|
@@ -69,6 +72,18 @@ function isObject(v) {
|
|
|
69
72
|
return v !== null && typeof v === "object" && !Array.isArray(v)
|
|
70
73
|
}
|
|
71
74
|
|
|
75
|
+
function pluginSafeId(pluginId) {
|
|
76
|
+
return String(pluginId || "").toLowerCase().replace(/[^a-z0-9_]/g, "_")
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function expectedPluginSchema(pluginId) {
|
|
80
|
+
return `app_${pluginSafeId(pluginId)}`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function expectedTablePrefix(pluginId) {
|
|
84
|
+
return `${pluginSafeId(pluginId)}__`
|
|
85
|
+
}
|
|
86
|
+
|
|
72
87
|
function unknownKeys(obj, allowed, label, errors) {
|
|
73
88
|
if (!isObject(obj)) return
|
|
74
89
|
for (const key of Object.keys(obj)) {
|
|
@@ -92,6 +107,71 @@ function validateArray(value, label, errors) {
|
|
|
92
107
|
if (value !== undefined && !Array.isArray(value)) errors.push(`${label} must be an array`)
|
|
93
108
|
}
|
|
94
109
|
|
|
110
|
+
function validateSecrets(value, errors) {
|
|
111
|
+
if (value === undefined) return
|
|
112
|
+
if (!isObject(value)) {
|
|
113
|
+
errors.push("secrets must be an object")
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
const allowed = new Set(["scope", "required", "label", "help", "validate"])
|
|
117
|
+
for (const [name, spec] of Object.entries(value)) {
|
|
118
|
+
const label = `secrets.${name}`
|
|
119
|
+
if (!/^[A-Z_][A-Z0-9_]*$/.test(name)) {
|
|
120
|
+
errors.push(`${label} must be an uppercase environment-style name`)
|
|
121
|
+
}
|
|
122
|
+
if (!isObject(spec)) {
|
|
123
|
+
errors.push(`${label} must be an object`)
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
unknownKeys(spec, allowed, label, errors)
|
|
127
|
+
const scopes = Array.isArray(spec.scope) ? spec.scope : [spec.scope || "dev"]
|
|
128
|
+
for (const scope of scopes) {
|
|
129
|
+
if (!SECRET_SCOPES.has(scope)) {
|
|
130
|
+
errors.push(`${label}.scope must be one of ${Array.from(SECRET_SCOPES).join(", ")}`)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
requireBoolean(spec, "required", label, errors)
|
|
134
|
+
requireString(spec, "label", label, errors)
|
|
135
|
+
requireString(spec, "help", label, errors)
|
|
136
|
+
requireString(spec, "validate", label, errors)
|
|
137
|
+
if (spec.validate !== undefined) {
|
|
138
|
+
try {
|
|
139
|
+
new RegExp(spec.validate)
|
|
140
|
+
} catch (err) {
|
|
141
|
+
errors.push(`${label}.validate must be a valid regular expression`)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function validatePlatformServices(value, errors) {
|
|
148
|
+
if (value === undefined) return
|
|
149
|
+
const known = new Set(["llm", "kv", "storage"])
|
|
150
|
+
if (Array.isArray(value)) {
|
|
151
|
+
for (const service of value) {
|
|
152
|
+
if (!known.has(service)) errors.push(`platform_services contains unknown service: ${service}`)
|
|
153
|
+
}
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
if (!isObject(value)) {
|
|
157
|
+
errors.push("platform_services must be an array or object")
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
for (const [name, spec] of Object.entries(value)) {
|
|
161
|
+
if (!known.has(name)) errors.push(`platform_services.${name} is not supported`)
|
|
162
|
+
if (!isObject(spec)) {
|
|
163
|
+
errors.push(`platform_services.${name} must be an object`)
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
166
|
+
unknownKeys(spec, new Set(["required", "billing"]), `platform_services.${name}`, errors)
|
|
167
|
+
requireBoolean(spec, "required", `platform_services.${name}`, errors)
|
|
168
|
+
requireString(spec, "billing", `platform_services.${name}`, errors)
|
|
169
|
+
if (spec.billing !== undefined && !["org_wallet", "plugin_owner", "platform"].includes(spec.billing)) {
|
|
170
|
+
errors.push(`platform_services.${name}.billing must be org_wallet, plugin_owner, or platform`)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
95
175
|
function validateManifest(m) {
|
|
96
176
|
const errors = []
|
|
97
177
|
if (!isObject(m)) return ["manifest must be an object"]
|
|
@@ -157,6 +237,9 @@ function validateManifest(m) {
|
|
|
157
237
|
errors.push("at least one of frontend, backend, or tools is required")
|
|
158
238
|
}
|
|
159
239
|
|
|
240
|
+
validateSecrets(m.secrets, errors)
|
|
241
|
+
validatePlatformServices(m.platform_services, errors)
|
|
242
|
+
|
|
160
243
|
if (m.sdk) {
|
|
161
244
|
if (!isObject(m.sdk)) errors.push("sdk must be an object")
|
|
162
245
|
else {
|
|
@@ -249,11 +332,21 @@ function validateManifest(m) {
|
|
|
249
332
|
}
|
|
250
333
|
if (m.database.schema !== undefined && !/^app_[a-z0-9_]+$/.test(m.database.schema)) {
|
|
251
334
|
errors.push("database.schema must match /^app_[a-z0-9_]+$/")
|
|
335
|
+
} else if (m.database.schema !== undefined && m.id && m.database.schema !== expectedPluginSchema(m.id)) {
|
|
336
|
+
errors.push(`database.schema must be ${expectedPluginSchema(m.id)} for plugin ${m.id}`)
|
|
252
337
|
}
|
|
253
338
|
}
|
|
254
339
|
}
|
|
255
340
|
|
|
256
341
|
validateArray(m.shared_tables, "shared_tables", errors)
|
|
342
|
+
if (Array.isArray(m.shared_tables) && m.id) {
|
|
343
|
+
const prefix = expectedTablePrefix(m.id)
|
|
344
|
+
for (const table of m.shared_tables) {
|
|
345
|
+
if (typeof table === "string" && !table.startsWith(prefix)) {
|
|
346
|
+
errors.push(`shared_tables entries must use the app table prefix "${prefix}": ${table}`)
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
257
350
|
validateArray(m.permissions, "permissions", errors)
|
|
258
351
|
if (Array.isArray(m.permissions)) {
|
|
259
352
|
const seen = new Set()
|
package/lib/secrets.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
const fs = require("fs")
|
|
4
|
+
const path = require("path")
|
|
5
|
+
|
|
6
|
+
const LOCAL_ENV_PATH = path.join(".palette", ".env.local")
|
|
7
|
+
const EXAMPLE_ENV_PATH = path.join(".palette", ".env.example")
|
|
8
|
+
const SECRET_SCOPES = new Set(["dev", "plugin", "install", "platform"])
|
|
9
|
+
|
|
10
|
+
function parseDotEnv(src) {
|
|
11
|
+
const values = {}
|
|
12
|
+
for (const rawLine of String(src || "").split(/\r?\n/)) {
|
|
13
|
+
const line = rawLine.trim()
|
|
14
|
+
if (!line || line.startsWith("#")) continue
|
|
15
|
+
const match = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/)
|
|
16
|
+
if (!match) continue
|
|
17
|
+
let value = match[2] || ""
|
|
18
|
+
if (
|
|
19
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
20
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
21
|
+
) {
|
|
22
|
+
value = value.slice(1, -1)
|
|
23
|
+
}
|
|
24
|
+
values[match[1]] = value
|
|
25
|
+
}
|
|
26
|
+
return values
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readDotEnvFile(filePath) {
|
|
30
|
+
if (!fs.existsSync(filePath)) return {}
|
|
31
|
+
return parseDotEnv(fs.readFileSync(filePath, "utf8"))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function writeDotEnvFile(filePath, values, manifest) {
|
|
35
|
+
const lines = [
|
|
36
|
+
"# Palette local developer secrets.",
|
|
37
|
+
"# This file is for pltt dev only and must not be committed.",
|
|
38
|
+
"",
|
|
39
|
+
]
|
|
40
|
+
for (const [name, meta] of Object.entries(values)) {
|
|
41
|
+
const scope = Array.isArray(meta.scope) ? meta.scope.join(",") : meta.scope
|
|
42
|
+
lines.push(`# ${name}`)
|
|
43
|
+
lines.push(`# scope: ${scope || "dev"}`)
|
|
44
|
+
if (meta.label) lines.push(`# label: ${meta.label}`)
|
|
45
|
+
if (meta.help) lines.push(`# help: ${meta.help}`)
|
|
46
|
+
lines.push(`${name}=`)
|
|
47
|
+
lines.push("")
|
|
48
|
+
}
|
|
49
|
+
if (Object.keys(values).length === 0 && manifest?.id) {
|
|
50
|
+
lines.push(`# No secrets are declared by ${manifest.id}.`)
|
|
51
|
+
lines.push("")
|
|
52
|
+
}
|
|
53
|
+
fs.writeFileSync(filePath, lines.join("\n"))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function ensurePaletteDir(cwd) {
|
|
57
|
+
const dir = path.join(cwd, ".palette")
|
|
58
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
59
|
+
return dir
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function ensureGitignore(cwd) {
|
|
63
|
+
const gitignorePath = path.join(cwd, ".gitignore")
|
|
64
|
+
const required = [
|
|
65
|
+
".palette/.env.local",
|
|
66
|
+
".palette/*.env",
|
|
67
|
+
".env",
|
|
68
|
+
".env.local",
|
|
69
|
+
".env.production",
|
|
70
|
+
]
|
|
71
|
+
let existing = ""
|
|
72
|
+
if (fs.existsSync(gitignorePath)) existing = fs.readFileSync(gitignorePath, "utf8")
|
|
73
|
+
const lines = existing ? existing.split(/\r?\n/) : []
|
|
74
|
+
let changed = false
|
|
75
|
+
for (const entry of required) {
|
|
76
|
+
if (!lines.includes(entry)) {
|
|
77
|
+
lines.push(entry)
|
|
78
|
+
changed = true
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (changed || !fs.existsSync(gitignorePath)) {
|
|
82
|
+
fs.writeFileSync(gitignorePath, lines.filter((line, index) => line || index < lines.length - 1).join("\n") + "\n")
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function normalizeScope(scope) {
|
|
87
|
+
if (Array.isArray(scope)) return scope
|
|
88
|
+
if (typeof scope === "string") return [scope]
|
|
89
|
+
return ["dev"]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function declaredSecrets(manifest) {
|
|
93
|
+
const raw = manifest?.secrets || {}
|
|
94
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}
|
|
95
|
+
const out = {}
|
|
96
|
+
for (const [name, meta] of Object.entries(raw)) {
|
|
97
|
+
if (!/^[A-Z_][A-Z0-9_]*$/.test(name)) continue
|
|
98
|
+
const item = meta && typeof meta === "object" && !Array.isArray(meta) ? meta : {}
|
|
99
|
+
out[name] = {
|
|
100
|
+
...item,
|
|
101
|
+
scope: normalizeScope(item.scope).filter((scope) => SECRET_SCOPES.has(scope)),
|
|
102
|
+
required: Boolean(item.required),
|
|
103
|
+
}
|
|
104
|
+
if (out[name].scope.length === 0) out[name].scope = ["dev"]
|
|
105
|
+
}
|
|
106
|
+
return out
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function secretsForScope(manifest, scope) {
|
|
110
|
+
return Object.fromEntries(
|
|
111
|
+
Object.entries(declaredSecrets(manifest)).filter(([, meta]) => meta.scope.includes(scope)),
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function initLocalEnv(cwd, manifest, { overwrite = false } = {}) {
|
|
116
|
+
ensurePaletteDir(cwd)
|
|
117
|
+
ensureGitignore(cwd)
|
|
118
|
+
const declared = declaredSecrets(manifest)
|
|
119
|
+
const localPath = path.join(cwd, LOCAL_ENV_PATH)
|
|
120
|
+
const examplePath = path.join(cwd, EXAMPLE_ENV_PATH)
|
|
121
|
+
if (overwrite || !fs.existsSync(localPath)) writeDotEnvFile(localPath, declared, manifest)
|
|
122
|
+
writeDotEnvFile(examplePath, declared, manifest)
|
|
123
|
+
return { localPath, examplePath, declared }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function loadLocalEnv(cwd, { apply = true } = {}) {
|
|
127
|
+
const values = readDotEnvFile(path.join(cwd, LOCAL_ENV_PATH))
|
|
128
|
+
if (apply) {
|
|
129
|
+
for (const [key, value] of Object.entries(values)) {
|
|
130
|
+
if (process.env[key] === undefined) process.env[key] = value
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return values
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function redactValue(value) {
|
|
137
|
+
if (!value) return ""
|
|
138
|
+
const str = String(value)
|
|
139
|
+
if (str.length <= 8) return "********"
|
|
140
|
+
return `${str.slice(0, 3)}…${str.slice(-3)}`
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = {
|
|
144
|
+
EXAMPLE_ENV_PATH,
|
|
145
|
+
LOCAL_ENV_PATH,
|
|
146
|
+
SECRET_SCOPES,
|
|
147
|
+
declaredSecrets,
|
|
148
|
+
ensureGitignore,
|
|
149
|
+
initLocalEnv,
|
|
150
|
+
loadLocalEnv,
|
|
151
|
+
parseDotEnv,
|
|
152
|
+
readDotEnvFile,
|
|
153
|
+
redactValue,
|
|
154
|
+
secretsForScope,
|
|
155
|
+
}
|
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@ from palette_sdk.db import OrgScopedTable
|
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
class Note(OrgScopedTable):
|
|
8
|
-
__tablename__ = "
|
|
8
|
+
__tablename__ = "my_db_plugin__notes"
|
|
9
9
|
|
|
10
10
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
11
11
|
body: Mapped[str] = mapped_column(Text, nullable=False)
|
|
@@ -11,15 +11,15 @@ down_revision = None
|
|
|
11
11
|
|
|
12
12
|
def upgrade() -> None:
|
|
13
13
|
op.create_table(
|
|
14
|
-
"
|
|
14
|
+
"my_db_plugin__notes",
|
|
15
15
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
|
16
16
|
sa.Column("organization_id", sa.BigInteger(), nullable=False),
|
|
17
17
|
sa.Column("body", sa.Text(), nullable=False),
|
|
18
18
|
)
|
|
19
|
-
op.create_index("
|
|
20
|
-
ensure_org_rls(op, "
|
|
19
|
+
op.create_index("ix_my_db_plugin__notes_org", "my_db_plugin__notes", ["organization_id"])
|
|
20
|
+
ensure_org_rls(op, "my_db_plugin__notes")
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
def downgrade() -> None:
|
|
24
|
-
op.drop_index("
|
|
25
|
-
op.drop_table("
|
|
24
|
+
op.drop_index("ix_my_db_plugin__notes_org", table_name="my_db_plugin__notes")
|
|
25
|
+
op.drop_table("my_db_plugin__notes")
|