Plugin Author Guide
How to build a third-party StreetJS plugin that is safe, installable, and trustworthy. StreetJS plugins are plain, dependency-light packages that implement a small contract and ship a signed manifest so consumers can verify provenance.
Prerequisites: a published understanding of Plugin Certification and a TypeScript package targeting Node ≥ 20, ESM (
"type": "module").
1. Package layout
1
2
3
4
5
6
7
8
my-plugin/
├── src/index.ts # plugin implementation + exported factory
├── manifest.json # name, version, capabilities (source of truth)
├── manifest.signed.json # generated at publish: checksum + signature
├── manifest.pub # generated at publish: public key (verification)
├── scripts/sign.mjs # signing step (runs ONLY at publish)
├── package.json
└── tsconfig.json
package.json scripts follow the official pattern — build never signs:
1
2
3
4
5
6
7
{
"scripts": {
"build": "tsc",
"sign": "node scripts/sign.mjs",
"prepublishOnly": "npm run clean && npm run build && npm run sign"
}
}
This separation is a hard rule (see §5): a plain npm run build must never mutate
manifest.signed.json/manifest.pub. Signing happens only during npm publish,
and only when the signing key is present.
2. The plugin contract
A plugin exports a factory and a manifest. Keep runtime dependencies minimal —
prefer Node core and @streetjs/* peers over third-party packages.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// src/index.ts
export const MY_PLUGIN_NAME = '@acme/streetjs-plugin-acme';
export const MY_PLUGIN_VERSION = '1.0.0';
export interface AcmeConfig {
apiKey: string;
baseUrl?: string;
}
/** Validate config defensively — accept valid, throw on invalid (never trust input). */
export function validateAcmeConfig(input: unknown): AcmeConfig {
const o = (input ?? {}) as Record<string, unknown>;
if (typeof o.apiKey !== 'string' || o.apiKey.trim() === '') {
throw new Error('acme plugin config: "apiKey" must be a non-empty string');
}
if (o.baseUrl !== undefined && typeof o.baseUrl !== 'string') {
throw new Error('acme plugin config: "baseUrl" must be a string');
}
return { apiKey: o.apiKey, ...(o.baseUrl ? { baseUrl: o.baseUrl } : {}) };
}
export class AcmePlugin {
constructor(private readonly cfg: AcmeConfig) {}
// ... capabilities the plugin exposes, built on Node core / public APIs only.
}
/** Factory used by consumers. */
export function acmePlugin(config: unknown): AcmePlugin {
return new AcmePlugin(validateAcmeConfig(config));
}
Guidelines:
- Validate all config with a
validate*function that throws on bad input (this is checked by the runtime certification harness for official plugins). - No regex on uncontrolled input that can backtrack (avoid
js/polynomial-redos); usestartsWith/slice/linear scans. - No secrets in the bundle; read credentials from config/env at runtime.
- Never reach into core internals — depend on documented
@streetjs/*APIs.
3. The manifest
manifest.json is the source of truth describing the plugin:
1
2
3
4
5
6
{
"name": "@acme/streetjs-plugin-acme",
"version": "1.0.0",
"capabilities": ["http-client"],
"engines": { "streetjs": ">=1.0.0" }
}
At publish time the signing step produces:
manifest.signed.json— the manifest plus a SHA-256checksumand an Ed25519signature.manifest.pub— the SPKI public key consumers use to verify the signature.
4. Signing your plugin
Generate a stable Ed25519 key once and keep the private key secret (a CI secret — never in the repo):
1
node -e "const {generateKeyPairSync}=require('crypto');const{privateKey,publicKey}=generateKeyPairSync('ed25519');console.log(privateKey.export({type:'pkcs8',format:'pem'}).toString())" > signing-key.pem
Your scripts/sign.mjs must require the key and fail without it, so an
unsigned/ephemerally-signed package can never be published:
1
2
3
4
5
6
7
8
import { signManifest, verifyManifest } from 'streetjs';
import { createPrivateKey, createPublicKey } from 'node:crypto';
const envKey = process.env.PLUGIN_SIGNING_KEY;
if (!envKey) { console.error('FATAL: PLUGIN_SIGNING_KEY not set'); process.exit(1); }
const privateKey = createPrivateKey(envKey);
const signed = signManifest(JSON.parse(/* manifest.json */), privateKey);
// write manifest.signed.json + manifest.pub (public key), verify before writing.
Publish from CI with the key injected as a secret:
1
2
3
4
5
- name: Publish
env:
PLUGIN_SIGNING_KEY: ${{ secrets.PLUGIN_SIGNING_KEY }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish --provenance --access public
Consumers verify with the framework’s verifyManifest() against your published
manifest.pub.
5. CI hygiene (required)
Add a check that a build never mutates signed artifacts — this catches the classic “build re-signs with an ephemeral key” footgun:
1
2
- run: npm run build
- run: git diff --exit-code # fails if build changed manifest.signed.json/.pub
The official StreetJS monorepo enforces exactly this in
runtime-certification.yml; mirror it in your plugin repo.
6. Validate at runtime
Before publishing, prove your plugin installs, imports, initializes, validates config, and verifies its own signature:
1
2
3
4
5
6
7
8
npm pack # build the publishable tarball
npm install ./acme-*.tgz # install as a consumer would
node -e "import('@acme/streetjs-plugin-acme').then(m => {
if (typeof m.acmePlugin !== 'function') throw new Error('missing factory');
try { m.validateAcmeConfig({}); throw new Error('should reject'); } catch {}
m.acmePlugin({ apiKey: 'k' }); // valid path
console.log('plugin OK');
})"
7. Certification path
| Level | Requirements |
|---|---|
| Community | Published, signed, validates config, no core-internal imports |
| Verified | Above + CI (build clean + tests), provenance attestation, SemVer policy |
| Official | Maintained in-tree, signed by the official key, covered by the runtime certification suite |
See Plugin Certification for the full criteria and how to submit your plugin for review.
Checklist before publishing
buildistsconly — never signs.prepublishOnlyrunsbuild && sign;signfails without the key.validate*rejects invalid config; no ReDoS-prone regex on input.- No secrets in the bundle; no core-internal imports; minimal dependencies.
npm pack+ install + import smoke passes.- CI asserts
git diff --exit-codeis clean afterbuild. - Provenance enabled on publish (
--provenance).