@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 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__ = "invoices"
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, "invoices")
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 OrgScopedTable, PluginBase, ensure_org_rls
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__ = "expenses"
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
- "expenses",
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, "expenses")
32
+ ensure_org_rls(op, "my_app__expenses")
33
33
  """
34
34
 
35
- from palette_sdk.db.base import OrgScopedTable, PluginBase
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__ = ["OrgScopedTable", "PluginBase", "ensure_org_rls"]
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__ = "invoices"
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. Do not manually set
195
- `organization_id` in route code; `ctx.repo(Model)` handles it.
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
- "invoices",
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("ix_invoices_org", "invoices", ["organization_id"])
221
- ensure_org_rls(op, "invoices")
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("invoices")
227
+ op.drop_table("finance_tools__invoices")
226
228
  ```
227
229
 
228
- `ensure_org_rls(op, "invoices")` enables row-level security so each
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
- `ctx.secret("KEY")` first checks app config secrets and then falls back to the
480
- process environment variable named `KEY`.
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) {
@@ -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 lintMigrationFile(absPath) {
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
- if (/#\s*palette:rls-ok\b/.test(src)) return issues
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
@@ -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 = []
@@ -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)
@@ -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
- const after = before.split(originalId).join(slug)
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_${slug.replace(/-/g, "_")}`
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:")
@@ -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
@@ -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(
@@ -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
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@palettelab/cli",
3
- "version": "0.3.29",
3
+ "version": "0.3.31",
4
4
  "description": "Developer CLI for building Palette platform plugins — no platform source access required.",
5
5
  "bin": {
6
6
  "pltt": "bin/pltt.js"
@@ -5,7 +5,7 @@ from palette_sdk.db import OrgScopedTable
5
5
 
6
6
 
7
7
  class Note(OrgScopedTable):
8
- __tablename__ = "notes"
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
- "notes",
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("ix_notes_org", "notes", ["organization_id"])
20
- ensure_org_rls(op, "notes")
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("ix_notes_org", table_name="notes")
25
- op.drop_table("notes")
24
+ op.drop_index("ix_my_db_plugin__notes_org", table_name="my_db_plugin__notes")
25
+ op.drop_table("my_db_plugin__notes")