@palettelab/cli 0.3.30 → 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
@@ -441,6 +441,36 @@ Environment variables:
441
441
  | `PALETTE_DEV_DATABASE_URL` | `.palette/dev/<plugin-id>.sqlite3` | Override the local dev database URL |
442
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 |
443
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
+
444
474
  ### `pltt login`
445
475
 
446
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
@@ -33,6 +33,7 @@ from palette_sdk.testing import route_permission_issues
33
33
  __all__ = [
34
34
  "PluginRouter",
35
35
  "PluginContext",
36
+ "MissingSecretError",
36
37
  "get_plugin_context",
37
38
  "DataRoomsClient",
38
39
  "OrgRepository",
@@ -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]):
@@ -478,8 +478,32 @@ async def sync_config(ctx: PluginContext = Depends(get_plugin_context)):
478
478
  }
479
479
  ```
480
480
 
481
- `ctx.secret("KEY")` first checks app config secrets and then falls back to the
482
- 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.
483
507
 
484
508
  ## 10. Lifecycle Hooks
485
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) {
@@ -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
@@ -165,6 +166,12 @@ async function run(args, { cwd }) {
165
166
  fs.cpSync(tmp, destDir, { recursive: true })
166
167
  fs.rmSync(tmp, { recursive: true, force: true })
167
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
+ }
168
175
 
169
176
  console.log(`[pltt] created ${destDir}`)
170
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
@@ -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) {
@@ -104,6 +107,71 @@ function validateArray(value, label, errors) {
104
107
  if (value !== undefined && !Array.isArray(value)) errors.push(`${label} must be an array`)
105
108
  }
106
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
+
107
175
  function validateManifest(m) {
108
176
  const errors = []
109
177
  if (!isObject(m)) return ["manifest must be an object"]
@@ -169,6 +237,9 @@ function validateManifest(m) {
169
237
  errors.push("at least one of frontend, backend, or tools is required")
170
238
  }
171
239
 
240
+ validateSecrets(m.secrets, errors)
241
+ validatePlatformServices(m.platform_services, errors)
242
+
172
243
  if (m.sdk) {
173
244
  if (!isObject(m.sdk)) errors.push("sdk must be an object")
174
245
  else {
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.30",
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"