@synoi/gateway-lite
v0.1.1
Published
Self-hosted SynOI lite daemon: local governed-action approval with operator-owned self-signed receipts. No account, no managed custody, no network call to run it.
Maintainers
Readme
@synoi/gateway-lite
SynOI Gateway Lite: a self-hosted local daemon for governed-action approval with operator-owned, self-signed receipts. No account, no managed key custody, no network call required to run it.
Status: PARTIAL-against-test-keys, PARTIAL-pending-Security+Adversary-panel. See "What gets signed, and by whom" and "Where the key lives, and who else can use it" below before relying on this for anything beyond local dogfood.
What this is
A single process that:
- Accepts a governed action from your code (
gate()via@synoi/sdkin local mode, or a rawPOST /local/gate). - Shows it to you, the operator, in a local dashboard for approve/deny.
- Signs a receipt for the decision with a key generated once on your own machine and never transmitted anywhere.
It excludes, at the architecture level (not just by policy), every premium channel
adapter (Composio, Slack, SMS, voice, n8n/Zapier, home automation, AR overlay, and
the 23 MCP shim integrations), the control-plane / license client, and the KMS
managed-custody signing tier (the AWS Secrets Manager-backed key provider the full
gateway uses in production). Those are the paid, hosted product; this package
literally cannot reach any of that code -- verified two ways in the source repo's
CI, not by inspection: (1) a dependency-graph assertion over daemon.ts's actual
TypeScript import graph, and (2) a byte-level content scan of the REAL packed
tarball (the exact bytes npm install @synoi/gateway-lite would fetch) that fails if
any shipped JavaScript (.js) file contains an AWS SDK reference anywhere,
comments included. The
KMS provider is a complete, working AWS Secrets Manager client -- it lives in its
own source module, reached by the full gateway only through a runtime
require() call, and that module is never compiled into this package: it is not
merely unused, it is structurally absent from what you install.
Quickstart
npm i @synoi/sdk
npx @synoi/gateway-liteThe daemon listens on http://127.0.0.1:8787 by default (override with
SYNOI_LITE_PORT). Open http://127.0.0.1:8787/local/dashboard in a browser to
enroll your operator identity (a keypair generated in the browser via the Web
Crypto API; the private key never leaves it) and approve/deny pending actions.
From your application:
import { gate } from '@synoi/sdk'
await gate({
action_kind: 'command',
args: { to: '[email protected]', subject: 'deploy complete' },
daemonUrl: 'http://127.0.0.1:8787', // or SYNOI_DAEMON_URL env
}, async () => {
// runs only if the operator approves
await sendEmail(...)
})What gets signed, and by whom
The first time the daemon runs, it generates a single Ed25519 keypair for you, the
operator. Every decision receipt after that is self-signed with that SAME key using
@synoi/gap's receipt() one-liner -- the single-Ed25519 self-sign CDRO shape,
receipt_scheme: "synoi.receipt/gap-selfsign". Verify any receipt offline with the
published @synoi/verify package:
import { verifyReceiptByScheme } from '@synoi/verify'
const result = await verifyReceiptByScheme({
receipt, // fetched from GET /local/receipts/:oid
gap_ed25519_pub: pubkey, // the 32-byte raw public key (see "Where the key lives" below)
})
// result.valid === true, result.scheme === 'gap-selfsign'Honest disclosure: this proves the receipt was signed by, and has not been
altered since, the key on YOUR machine. It does not yet prove that key belongs to
a trustworthy party to anyone else -- a neutral, third-party-hosted resolver
(oid.synoi.systems) is planned but not live. Do not describe a self-signed
receipt as "independently verified."
Where the key lives, and who else can use it
Any process running as your OS user account can sign as the operator using this key. This key has no managed custody and no account recovery: if it is lost, it is gone, and there is no SynOI-side way to get it back or re-sign past receipts under a continuous identity. This is the tradeoff of self-hosting with no managed service in the loop; it is not a bug to be fixed later, it is what "no account, no network call" means.
The daemon tries to reduce the blast radius of that tradeoff by wrapping the key with your OS's own credential store where one is available:
- Windows -- DPAPI (
System.Security.Cryptography.ProtectedData, current-user scope). The private key is written toSYNOI_DATA_DIR/lite-signing-key.dpapias ciphertext only your Windows user account can decrypt;SYNOI_DATA_DIR/lite-signing-key.jsonholds only the public key and metadata. - macOS -- the system Keychain, via the
securitycommand-line tool every macOS install ships with (implemented per documented behavior; not yet executable-tested on this platform; the daemon prints the actual path taken, and any fallback, on every boot). - Linux --
secret-tool(libsecret / gnome-keyring). Requires a running secret-service provider, which is normal on a desktop session and often ABSENT on a minimal/headless/server install -- that is an expected reason to hit the fallback below, not a bug (implemented per documented behavior; not yet executable-tested on this platform; the daemon prints the actual path taken, and any fallback, on every boot).
Important: the OS keystore protects the key from OTHER user accounts on the same machine. It does NOT protect the key from other processes running as YOUR OWN account -- any of those can still ask the OS to unwrap it, same as they could read any other file you own. Treat the machine and your OS login as the trust boundary, not this daemon.
If no OS keystore is available (or it fails), the daemon falls back to a plaintext
JSON file at SYNOI_DATA_DIR/lite-signing-key.json (private_key_hex present
directly), protected by whatever your OS's own file permissions provide: POSIX
mode 0600 on macOS/Linux (kernel-enforced), or a Windows ACL restricted to your
account only (icacls ... /grant:r <you>:F, applied automatically -- note that
POSIX mode bits like 0600 are silently a no-op on Windows NTFS, which is why
this is a real ACL call, not just a mode flag). The daemon prints which path it
took, and any fallback reason, on every boot.
Environment variables
| Variable | Purpose | Default |
|---|---|---|
| SYNOI_LITE_PORT / PORT | Listen port | 8787 |
| SYNOI_DATA_DIR | Where the operator signing key, enrollment record, and SQLite store live | ~/.synoi |
| SYNOI_ALLOW_EPHEMERAL_KEYS | Currently required for POST /local/gate to accept the bootstrap hero-bundle OID capbundle/1 without a real capability-grant record (lite has no grant-issuance ceremony yet). NOT needed for key generation, receipt signing, or the operator-enrollment record, which survive a restart on the persisted operator key regardless of this variable. Set true; this requirement is expected to be removed once a real grant ceremony ships. | required for POST /local/gate, no default |
| SYNOI_DASHBOARD_DIR | Serve a built portal SPA instead of the built-in self-contained HTML dashboard | unset (uses the built-in dashboard) |
The daemon boots with zero AWS credentials, zero control-plane URL, and zero license key -- there is nothing in the shipped package that could reach any of those: no reachable import, and (independently checked) no AWS-SDK-related byte sequence anywhere in the packed tarball (enforced by CI, see "Architecture" below).
Architecture (why this is small)
This package is NOT a slice of the full SynOI gateway shipped to a subdirectory.
It bundles only the source files src/daemon.ts actually imports, transitively,
in the gateway's monorepo -- 18 files, six runtime npm dependencies (express,
better-sqlite3, @synoi/gap, @synoi/sraid, @noble/curves,
@noble/post-quantum), no channel adapters, no license client, no cloud SDK of
any kind. The gateway repo's CI checks this two ways, both required to pass
before a build is produced:
- A dependency-graph assertion over
daemon.ts's real TypeScript import graph (ts.createProgram, not a regex), asserting it never reaches the channel adapters, the license client, the KMS-hybrid signing tier, or the AWS Secrets Manager key provider module. - A byte-level content scan of the ACTUAL packed tarball (a real
npm pack, extracted and read file by file, not--dry-run's file-name listing): fails if any shipped.jsfile contains an AWS-SDK-related string anywhere, including inside comments. This is the check that would have caught what the dependency-graph assertion alone could not: a file can be legitimately reachable (this package's own key-provider module is; it backs the self-host key-generation path) while a DIFFERENT class defined in the same file carries a cloud-SDK reference that a pure import-graph walk cannot see. The fix was to move that class into its own module, reached only by a runtimerequire()the static graph walk does not follow -- so its compiled output, and the SDK reference inside it, never enter this package'sdist/at all.
This package's own build step refuses to produce a build if either assertion would fail.
Repository
This package currently lives in packages/gateway-lite/ of the synoi-gateway
repository (private) pending extraction to a dedicated public repository before
first publish. That extraction, and the first npm publish, are human-gated
steps not taken by this build.
License
Apache-2.0
