feat(app): node-override secrets UX + supabase scaffold refresh
- Secrets page: 'Node overrides' section (per-node values that shadow a shared
secret on that node) — list/reveal/edit/delete + add; shared secrets show an
'override: <node>' badge. Authority-only writes.
- API: GET /secrets/overrides, GET/PUT/DELETE /secrets/overrides/{node}/{name};
OpenBaoBackend.list_node_overrides enumerates castle/nodes/*
- supabase scaffold: drop stale 'cat ~/.castle/secrets/...' + 'castle deploy',
point at the dashboard Secrets page / castle apply
- tests: file-backend override endpoints (empty/400/404)
Verified live: primer's postgres override discoverable + full CRUD round-trip.
This commit is contained in:
@@ -9,14 +9,18 @@ import {
|
|||||||
Lock,
|
Lock,
|
||||||
Plus,
|
Plus,
|
||||||
Save,
|
Save,
|
||||||
|
Server,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { PageHeader } from "@/components/PageHeader"
|
import { PageHeader } from "@/components/PageHeader"
|
||||||
import { apiClient } from "@/services/api/client"
|
import { apiClient } from "@/services/api/client"
|
||||||
import {
|
import {
|
||||||
|
useDeleteOverride,
|
||||||
useDeleteSecret,
|
useDeleteSecret,
|
||||||
|
useSecretOverrides,
|
||||||
useSecrets,
|
useSecrets,
|
||||||
useSecretsInfo,
|
useSecretsInfo,
|
||||||
|
useSetOverride,
|
||||||
useSetSecret,
|
useSetSecret,
|
||||||
} from "@/services/api/hooks"
|
} from "@/services/api/hooks"
|
||||||
|
|
||||||
@@ -47,7 +51,15 @@ async function writeClipboard(text: string): Promise<boolean> {
|
|||||||
export function SecretsPage() {
|
export function SecretsPage() {
|
||||||
const { data: info } = useSecretsInfo()
|
const { data: info } = useSecretsInfo()
|
||||||
const { data: names, isLoading } = useSecrets()
|
const { data: names, isLoading } = useSecrets()
|
||||||
|
const { data: overridesResp } = useSecretOverrides()
|
||||||
const writable = info?.writable ?? false
|
const writable = info?.writable ?? false
|
||||||
|
const overrides = overridesResp?.overrides ?? {}
|
||||||
|
|
||||||
|
// name -> [nodes that override it], for the per-secret badge.
|
||||||
|
const overriddenBy: Record<string, string[]> = {}
|
||||||
|
for (const [node, secretNames] of Object.entries(overrides)) {
|
||||||
|
for (const n of secretNames) (overriddenBy[n] ??= []).push(node)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto px-6 py-8">
|
<div className="max-w-4xl mx-auto px-6 py-8">
|
||||||
@@ -78,15 +90,30 @@ export function SecretsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="mt-2 divide-y divide-[var(--border)] rounded border border-[var(--border)]">
|
<div className="mt-2 divide-y divide-[var(--border)] rounded border border-[var(--border)]">
|
||||||
{names.map((name) => (
|
{names.map((name) => (
|
||||||
<SecretRow key={name} name={name} writable={writable} />
|
<SecretRow
|
||||||
|
key={name}
|
||||||
|
name={name}
|
||||||
|
writable={writable}
|
||||||
|
overriddenOn={overriddenBy[name] ?? []}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<NodeOverrides overrides={overrides} writable={writable} secretNames={names ?? []} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SecretRow({ name, writable }: { name: string; writable: boolean }) {
|
function SecretRow({
|
||||||
|
name,
|
||||||
|
writable,
|
||||||
|
overriddenOn = [],
|
||||||
|
}: {
|
||||||
|
name: string
|
||||||
|
writable: boolean
|
||||||
|
overriddenOn?: string[]
|
||||||
|
}) {
|
||||||
const [value, setValue] = useState<string | null>(null)
|
const [value, setValue] = useState<string | null>(null)
|
||||||
const [visible, setVisible] = useState(false)
|
const [visible, setVisible] = useState(false)
|
||||||
const [dirty, setDirty] = useState(false)
|
const [dirty, setDirty] = useState(false)
|
||||||
@@ -127,6 +154,14 @@ function SecretRow({ name, writable }: { name: string; writable: boolean }) {
|
|||||||
<div className="flex items-center gap-2 px-3 py-2">
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
<span className="w-40 sm:w-64 shrink-0 truncate font-mono text-xs" title={name}>
|
<span className="w-40 sm:w-64 shrink-0 truncate font-mono text-xs" title={name}>
|
||||||
{name}
|
{name}
|
||||||
|
{overriddenOn.length > 0 && (
|
||||||
|
<span
|
||||||
|
className="ml-1.5 rounded bg-amber-900/40 px-1 text-[9px] text-amber-300"
|
||||||
|
title={`Overridden on: ${overriddenOn.join(", ")}`}
|
||||||
|
>
|
||||||
|
override: {overriddenOn.join(", ")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type={visible ? "text" : "password"}
|
type={visible ? "text" : "password"}
|
||||||
@@ -227,3 +262,177 @@ function AddSecret() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NodeOverrides({
|
||||||
|
overrides,
|
||||||
|
writable,
|
||||||
|
secretNames,
|
||||||
|
}: {
|
||||||
|
overrides: Record<string, string[]>
|
||||||
|
writable: boolean
|
||||||
|
secretNames: string[]
|
||||||
|
}) {
|
||||||
|
const entries = Object.entries(overrides).flatMap(([node, names]) =>
|
||||||
|
names.map((name) => ({ node, name })),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-8">
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<Server size={14} className="text-[var(--primary)]" />
|
||||||
|
<h2 className="text-sm font-semibold">Node overrides</h2>
|
||||||
|
<span className="text-xs text-[var(--muted)]">
|
||||||
|
per-node values (e.g. a node's own postgres password); shadow the shared secret on that node
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{writable && <AddOverride secretNames={secretNames} />}
|
||||||
|
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<p className="text-sm text-[var(--muted)]">No overrides.</p>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-[var(--border)] rounded border border-[var(--border)]">
|
||||||
|
{entries.map(({ node, name }) => (
|
||||||
|
<OverrideRow key={`${node}/${name}`} node={node} name={name} writable={writable} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverrideRow({ node, name, writable }: { node: string; name: string; writable: boolean }) {
|
||||||
|
const [value, setValue] = useState<string | null>(null)
|
||||||
|
const [visible, setVisible] = useState(false)
|
||||||
|
const [dirty, setDirty] = useState(false)
|
||||||
|
const setOverride = useSetOverride()
|
||||||
|
const delOverride = useDeleteOverride()
|
||||||
|
|
||||||
|
const reveal = async () => {
|
||||||
|
if (value === null) {
|
||||||
|
const data = await apiClient
|
||||||
|
.get<{ value: string }>(`/secrets/overrides/${node}/${name}`)
|
||||||
|
.catch(() => null)
|
||||||
|
setValue(data?.value ?? "")
|
||||||
|
}
|
||||||
|
setVisible((v) => !v)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
|
<span className="w-24 sm:w-36 shrink-0 truncate font-mono text-xs text-amber-300" title={node}>
|
||||||
|
{node}
|
||||||
|
</span>
|
||||||
|
<span className="w-32 sm:w-52 shrink-0 truncate font-mono text-xs" title={name}>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type={visible ? "text" : "password"}
|
||||||
|
value={value ?? "••••••••"}
|
||||||
|
readOnly={!writable || value === null}
|
||||||
|
onChange={(e) => {
|
||||||
|
setValue(e.target.value)
|
||||||
|
setDirty(true)
|
||||||
|
}}
|
||||||
|
className="flex-1 min-w-0 rounded border border-[var(--border)] bg-black/30 px-2 py-1 text-xs font-mono focus:border-[var(--primary)] focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button onClick={reveal} className="p-1 shrink-0 text-[var(--muted)] hover:text-[var(--foreground)]" title="Reveal">
|
||||||
|
{visible ? <EyeOff size={13} /> : <Eye size={13} />}
|
||||||
|
</button>
|
||||||
|
{writable && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (value !== null && dirty)
|
||||||
|
setOverride.mutate({ node, name, value }, { onSuccess: () => setDirty(false) })
|
||||||
|
}}
|
||||||
|
disabled={!dirty || setOverride.isPending}
|
||||||
|
className="p-1 shrink-0 text-blue-400 hover:text-blue-300 disabled:opacity-30"
|
||||||
|
title="Save"
|
||||||
|
>
|
||||||
|
{setOverride.isPending ? <Loader2 size={13} className="animate-spin" /> : <Save size={13} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Delete override "${name}" for ${node}?`))
|
||||||
|
delOverride.mutate({ node, name })
|
||||||
|
}}
|
||||||
|
className="p-1 shrink-0 text-red-400 hover:text-red-300"
|
||||||
|
title="Delete override"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddOverride({ secretNames }: { secretNames: string[] }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [node, setNode] = useState("")
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [value, setValue] = useState("")
|
||||||
|
const setOverride = useSetOverride()
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
if (!node.trim() || !name.trim() || !value) return
|
||||||
|
setOverride.mutate(
|
||||||
|
{ node: node.trim(), name: name.trim(), value },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setNode("")
|
||||||
|
setName("")
|
||||||
|
setValue("")
|
||||||
|
setOpen(false)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return (
|
||||||
|
<button onClick={() => setOpen(true)} className="mb-2 text-xs text-[var(--primary)] hover:underline">
|
||||||
|
<Plus size={11} className="inline mr-1" />
|
||||||
|
Add override
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-3 flex flex-wrap items-center gap-2 rounded border border-[var(--border)] bg-black/20 p-2">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
placeholder="node (host)"
|
||||||
|
value={node}
|
||||||
|
onChange={(e) => setNode(e.target.value)}
|
||||||
|
className="w-32 rounded border border-[var(--border)] bg-black/30 px-2 py-1 text-xs font-mono focus:border-[var(--primary)] focus:outline-none"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="SECRET_NAME"
|
||||||
|
list="secret-names"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="w-44 rounded border border-[var(--border)] bg-black/30 px-2 py-1 text-xs font-mono focus:border-[var(--primary)] focus:outline-none"
|
||||||
|
/>
|
||||||
|
<datalist id="secret-names">
|
||||||
|
{secretNames.map((n) => (
|
||||||
|
<option key={n} value={n} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
<input
|
||||||
|
placeholder="value"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && submit()}
|
||||||
|
className="flex-1 min-w-0 rounded border border-[var(--border)] bg-black/30 px-2 py-1 text-xs font-mono focus:border-[var(--primary)] focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button onClick={submit} disabled={setOverride.isPending} className="rounded bg-[var(--primary)] px-2 py-1 text-xs text-black disabled:opacity-40">
|
||||||
|
{setOverride.isPending ? "…" : "Add"}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setOpen(false)} className="px-2 py-1 text-xs text-[var(--muted)]">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -583,3 +583,29 @@ export function useDeleteSecret() {
|
|||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["secrets"] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["secrets"] }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useSecretOverrides() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["secret-overrides"],
|
||||||
|
queryFn: () =>
|
||||||
|
apiClient.get<{ overrides: Record<string, string[]> }>("/secrets/overrides"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetOverride() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ node, name, value }: { node: string; name: string; value: string }) =>
|
||||||
|
apiClient.put(`/secrets/overrides/${node}/${name}`, { value }),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["secret-overrides"] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteOverride() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ node, name }: { node: string; name: string }) =>
|
||||||
|
apiClient.delete(`/secrets/overrides/${node}/${name}`),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["secret-overrides"] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,6 +45,48 @@ def secrets_info() -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/overrides")
|
||||||
|
def list_overrides() -> dict:
|
||||||
|
"""Per-node secret overrides ({host: [names]}). Empty unless OpenBao."""
|
||||||
|
backend = _backend()
|
||||||
|
if not isinstance(backend, OpenBaoBackend):
|
||||||
|
return {"overrides": {}}
|
||||||
|
return {"overrides": backend.list_node_overrides()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/overrides/{node}/{name:path}")
|
||||||
|
def get_override(node: str, name: str) -> dict:
|
||||||
|
"""Read a node's override value."""
|
||||||
|
value = _backend().read(f"nodes/{node}/{name}")
|
||||||
|
if value is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_404_NOT_FOUND, f"no override '{name}' for node '{node}'"
|
||||||
|
)
|
||||||
|
return {"node": node, "name": name, "value": value}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/overrides/{node}/{name:path}")
|
||||||
|
def set_override(node: str, name: str, body: SecretValue) -> dict:
|
||||||
|
"""Set a per-node override (authority only; needs the OpenBao backend)."""
|
||||||
|
backend = _backend()
|
||||||
|
if not isinstance(backend, OpenBaoBackend):
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST, "node overrides require the OpenBao backend"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
backend.write(f"nodes/{node}/{name}", body.value)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status.HTTP_403_FORBIDDEN, str(exc)) from exc
|
||||||
|
return {"node": node, "name": name, "ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/overrides/{node}/{name:path}")
|
||||||
|
def delete_override(node: str, name: str) -> dict:
|
||||||
|
"""Remove a per-node override."""
|
||||||
|
_backend().delete(f"nodes/{node}/{name}")
|
||||||
|
return {"node": node, "name": name, "ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{name}")
|
@router.get("/{name}")
|
||||||
def get_secret(name: str) -> dict:
|
def get_secret(name: str) -> dict:
|
||||||
"""Get a secret value."""
|
"""Get a secret value."""
|
||||||
|
|||||||
24
castle-api/tests/test_secrets_overrides.py
Normal file
24
castle-api/tests/test_secrets_overrides.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""HTTP-layer tests for the node-override endpoints (file-backend paths).
|
||||||
|
|
||||||
|
The live OpenBao override round-trip is verified manually; here we pin the
|
||||||
|
file-backend behavior (overrides are an OpenBao-only feature).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_overrides_empty_on_file_backend(client: TestClient) -> None:
|
||||||
|
r = client.get("/secrets/overrides")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"overrides": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_override_rejected_on_file_backend(client: TestClient) -> None:
|
||||||
|
r = client.put("/secrets/overrides/primer/POSTGRES_PASSWORD", json={"value": "x"})
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_missing_override_is_404(client: TestClient) -> None:
|
||||||
|
assert client.get("/secrets/overrides/primer/NOPE").status_code == 404
|
||||||
@@ -675,8 +675,8 @@ serve((_req: Request) => {
|
|||||||
project_dir / "public" / "config.js",
|
project_dir / "public" / "config.js",
|
||||||
sub(
|
sub(
|
||||||
"""// Substrate wiring for __NAME__. The anon key is designed to be public
|
"""// Substrate wiring for __NAME__. The anon key is designed to be public
|
||||||
// (RLS enforces access) — paste yours here, or inject at deploy time:
|
// (RLS enforces access) — paste yours here. Find it in the dashboard
|
||||||
// cat ~/.castle/secrets/SUPABASE_ANON_KEY
|
// Secrets page (or `castle mesh`/vault), secret name SUPABASE_ANON_KEY.
|
||||||
window.APP = {
|
window.APP = {
|
||||||
SUPABASE_URL: "https://supabase.lan",
|
SUPABASE_URL: "https://supabase.lan",
|
||||||
SUPABASE_ANON_KEY: "PASTE_ANON_KEY_HERE",
|
SUPABASE_ANON_KEY: "PASTE_ANON_KEY_HERE",
|
||||||
@@ -758,8 +758,9 @@ live on the substrate; rebuild the rest from git anytime.
|
|||||||
## Develop
|
## Develop
|
||||||
- Edit `migrations/`, then `castle program build __NAME__` to apply new migrations
|
- Edit `migrations/`, then `castle program build __NAME__` to apply new migrations
|
||||||
(re-running is a no-op — only unapplied migrations run).
|
(re-running is a no-op — only unapplied migrations run).
|
||||||
- Set the anon key in `public/config.js` (`cat ~/.castle/secrets/SUPABASE_ANON_KEY`).
|
- Set the anon key in `public/config.js` (find `SUPABASE_ANON_KEY` in the
|
||||||
- `castle deploy && castle gateway reload` → served at `/__NAME__/`.
|
dashboard Secrets page).
|
||||||
|
- `castle apply` → served at `__NAME__.<domain>`.
|
||||||
|
|
||||||
## Privacy note
|
## Privacy note
|
||||||
RLS protects rows, not the static shell or Storage. For a `private`/`shared` app,
|
RLS protects rows, not the static shell or Storage. For a `private`/`shared` app,
|
||||||
|
|||||||
@@ -104,15 +104,29 @@ class OpenBaoBackend:
|
|||||||
self._request("DELETE", url)
|
self._request("DELETE", url)
|
||||||
|
|
||||||
def list_names(self) -> list[str]:
|
def list_names(self) -> list[str]:
|
||||||
url = f"{self._addr}/v1/{self._mount}/metadata?list=true"
|
|
||||||
try:
|
|
||||||
data = self._request("GET", url)
|
|
||||||
keys = data.get("data", {}).get("keys", [])
|
|
||||||
# Drop folder entries (e.g. "nodes/") — those group per-node overrides.
|
# Drop folder entries (e.g. "nodes/") — those group per-node overrides.
|
||||||
return sorted(k for k in keys if not k.endswith("/"))
|
return sorted(k for k in self._list_path("") if not k.endswith("/"))
|
||||||
|
|
||||||
|
def _list_path(self, prefix: str) -> list[str]:
|
||||||
|
sep = "/" if prefix else ""
|
||||||
|
url = f"{self._addr}/v1/{self._mount}/metadata{sep}{prefix}?list=true"
|
||||||
|
try:
|
||||||
|
return self._request("GET", url).get("data", {}).get("keys", [])
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def list_node_overrides(self) -> dict[str, list[str]]:
|
||||||
|
"""Per-node secret overrides: ``{host: [names]}`` from ``<mount>/nodes/*``."""
|
||||||
|
out: dict[str, list[str]] = {}
|
||||||
|
for entry in self._list_path("nodes"):
|
||||||
|
if not entry.endswith("/"):
|
||||||
|
continue
|
||||||
|
host = entry.rstrip("/")
|
||||||
|
names = [k for k in self._list_path(f"nodes/{host}") if not k.endswith("/")]
|
||||||
|
if names:
|
||||||
|
out[host] = sorted(names)
|
||||||
|
return out
|
||||||
|
|
||||||
def _request(self, method: str, url: str, body: dict | None = None) -> dict:
|
def _request(self, method: str, url: str, body: dict | None = None) -> dict:
|
||||||
payload = json.dumps(body).encode() if body is not None else None
|
payload = json.dumps(body).encode() if body is not None else None
|
||||||
req = urllib.request.Request( # noqa: S310
|
req = urllib.request.Request( # noqa: S310
|
||||||
|
|||||||
Reference in New Issue
Block a user