streamlock
v0.1.0
Published
Runtime-agnostic subscription gating for HLS media. Rewrites AES-128 m3u8 key URIs, mints short-lived session tokens, and enforces per-episode entitlements on Cloudflare Workers, AWS Lambda@Edge, Google Cloud, or any Node 18+ runtime.
Maintainers
Readme
streamlock
English | 日本語
Runtime-agnostic subscription gating for HLS media.
streamlock is the extraction of the paywall that sits in front of koelink's
encrypted audio/video streams. It decides who may fetch an HLS manifest,
who may fetch the AES-128 decryption key, and rewrites the manifest so the
player asks your key endpoint instead of the raw enc.key.
The core has zero runtime dependencies and only uses Web-standard primitives
(Request/Response/URL/WebCrypto/fetch), so the same handler runs on:
- Cloudflare Workers (R2 + KV + Cache API)
- AWS (CloudFront + Lambda@Edge + S3 + DynamoDB)
- Google Cloud (Cloud Run / Cloud Functions + Cloud Storage + Firestore)
- Any Node 18+ server (ECS, Fargate, a VM, Fastly Compute, Deno, Bun)
In production, streamlock powers the media delivery for the voice-actor radio
platform https://koelink.co.jp (こえリンク) — every
premium episode's manifest, key and segment requests go through this gate.
How it works
┌──────────────────────────────────────────────┐
player ──────▶ │ streamlock (Worker / Lambda@Edge / Node) │
│ │
1. GET master.m3u8 ──▶ verify ID token ──▶ entitlement.check │
│ │ allowed? │
│ ▼ │
│ rewrite #EXT-X-KEY URI ──▶ /get-key?token= │
│ │
2. GET /get-key?token= ──▶ verify HMAC session token │
│ ──▶ read _private_keys/.../enc.key │
│ │
3. GET seg1.ts ──▶ (optional auth) ──▶ object storage ──▶ CDN │
└──────────────────────────────────────────────┘
│ │
entitlement store object storage
(KV / DynamoDB) (R2 / S3)- Manifest — for premium episodes the gate verifies the caller's ID token,
asks the entitlement adapter whether the user subscribes to the program, and
rewrites every
#EXT-X-KEYURI to…/get-key?token=<short-lived HMAC JWT>. The token only authorizes the one key object ({ objectKey }). - Key —
/get-keyverifies the HMAC token (no ID token needed; this is the one endpoint iOS AVFoundation calls withoutOrigin/Referer) and streams the AES-128 key. Keys areno-store. - Segments —
.ts/.vttare served from object storage with longCache-Control. Because the CDN caches by path (not by token), the gate runs roughly once per object per edge location, not once per viewer.
Gating happens on the manifest and the key. Segments themselves are encrypted, so a leaked segment URL is useless without the key. You can turn per-segment entitlement checks off with
checkAuthOnSegments: false.
Install
npm install streamlockAWS adapters additionally need the AWS SDK (peer dependencies):
npm install @aws-sdk/client-s3 @aws-sdk/lib-dynamodbQuick start
Cloudflare Workers
import { createMediaGate, createFirebaseVerifier } from "streamlock";
import { createCloudflareAdapters, createCloudflareCache } from "streamlock/adapters/cloudflare";
export default {
async fetch(request, env, ctx) {
const { storage, entitlement } = createCloudflareAdapters(env);
const gate = createMediaGate({
basePath: "/koelink/api",
allowedOrigins: [env.ALLOWED_ORIGIN],
storage,
entitlement,
verifyToken: createFirebaseVerifier({ projectId: env.FIREBASE_PROJECT_ID }),
hmacSecret: () => env.HMAC_SECRET,
cache: createCloudflareCache(),
});
return gate.handleRequest(request, { waitUntil: (fn) => ctx.waitUntil(fn) });
},
};Bindings: R2 MEDIA_BUCKET, KV premium and role. See
examples/cloudflare-worker.js.
AWS Lambda@Edge (CloudFront + S3 + DynamoDB)
import { createMediaGate, createFirebaseVerifier } from "streamlock";
import { createAwsAdapters } from "streamlock/adapters/aws";
import { S3Client } from "@aws-sdk/client-s3";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
const s3 = new S3Client({ region: "ap-northeast-1" });
const docClient = DynamoDBDocumentClient.from(new DynamoDBClient({ region: "ap-northeast-1" }));
const gate = createMediaGate({
basePath: "/koelink/api",
allowedOrigins: ["https://koelink.co.jp"],
...createAwsAdapters({ s3, bucket: "media", docClient, tableName: "entitlements" }),
verifyToken: createFirebaseVerifier({ projectId: "my-project" }),
hmacSecret: () => process.env.HMAC_SECRET,
});examples/aws-lambda-edge.js includes the CloudFront event ⇄ Request/Response
conversion. Build with esbuild:
npx esbuild examples/aws-lambda-edge.js --bundle --platform=node --target=node18 \
--outfile=dist/lambda.jsLambda@Edge caveats: no environment variables (bake config at build time, see
examples/aws-config.js), 1 MB unpacked bundle limit, and responses must be fully materialized. Keep the small requests (m3u8,get-key) on the function and let CloudFront cache the segments.
Google Cloud (Cloud Run / Cloud Functions + GCS + Firestore)
import { Storage } from "@google-cloud/storage";
import { Firestore } from "@google-cloud/firestore";
import { createMediaGate, createFirebaseVerifier } from "streamlock";
import { createGcpAdapters } from "streamlock/adapters/gcp";
const gate = createMediaGate({
basePath: "/koelink/api",
allowedOrigins: ["https://koelink.co.jp"],
...createGcpAdapters({
bucket: new Storage().bucket(process.env.MEDIA_BUCKET),
firestore: new Firestore(),
}),
verifyToken: createFirebaseVerifier({ projectId: process.env.FIREBASE_PROJECT_ID }),
hmacSecret: () => process.env.HMAC_SECRET,
});examples/gcp-cloud-run.js is a deployable Node server (PORT=8080) and exports a
nodeAdapter(req, res) you can wrap with firebase-functions/v2/https onRequest.
GCS metadata + Cloud CDN cache the segments; Cloud CDN keys on the path (not the
token), so cached bytes are shared across subscribers.
Any Node 18+ server
examples/express.js runs a demo with in-memory adapters:
node examples/express.jsAPI
createMediaGate(options)
Returns { handleRequest(request, ctx?) }. ctx may contain waitUntil(fn) for
runtimes that support background tasks.
| Option | Default | Purpose |
| --- | --- | --- |
| storage | — | { get(key) => Promise<StoredObject\|null> } |
| entitlement | — | { check({ userId, programId, claims }) => Promise<boolean> } |
| verifyToken | — | (token) => Promise<claims\|null> |
| hmacSecret | — | string / () => Promise<string> for session tokens |
| basePath | /api | URL prefix |
| allowedOrigins | [] | Origin/Referer allow-list; empty = open |
| sessionTokenTtlSeconds | 7200 | Key token lifetime |
| keyPrefix | _private_keys | Storage prefix for enc.key |
| episodePolicy | suffix -b/-c | { isPremium(episodeId) } |
| checkAuthOnSegments | true | Also gate .ts/.vtt |
| cache | — | { match, put } for segment caching |
| authCacheTtlSeconds | 300 | Entitlement memo TTL |
Auth
createJwksVerifier({ jwksUri, audience, issuer }) verifies any RS256 JWKS
issuer (Cognito, Auth0, …). createFirebaseVerifier({ projectId }) is a
convenience wrapper for Firebase Authentication.
Storage adapter contract
get(key: string): Promise<{
text(): Promise<string>; // manifests
body: ReadableStream | null; // segments / keys
contentType: string | null;
etag: string | null;
size: number | null;
} | null>Entitlement adapter contract
check({ userId, programId, claims }): Promise<boolean>The bundled adapters follow this entitlement convention:
- Cloudflare: KV
premium:<programId>:<userId>= "1", or KVadmin:<userId>/owner:<programId>:<userId>. - AWS: DynamoDB keys
premium:<programId>:<userId>,admin:<userId>,owner:<programId>:<userId>. - Google Cloud: ID-token claim
role("admin" / programId / array), or Firestoreusers/{userId}/premiums/{programId}withstatusinactive|trialingand a validcurrentPeriodEnd.
Security notes
- Session tokens are HS256, short-lived, and scoped to a single
objectKey. - The segment cache key excludes the token, so cached bytes are shared across subscribers. This is what keeps edge cost flat.
/get-keyintentionally skips the Origin/Referer check (iOS AVFoundation does not send them); it is protected by the signed token instead.- Entitlement results are memoized in-process for
authCacheTtlSeconds. Tune it to how quickly revoked subscriptions must take effect. - Revocation: keep key tokens short (60–300 s) and re-check the manifest on each playlist refresh.
How it compares
| | streamlock | Cloudflare Stream / Mux | Castopod / podcast hosts | firestore-stripe-payments | | --- | --- | --- | --- | --- | | Bring your own storage (R2/S3/GCS) | ✅ | ❌ | varies | n/a | | Per-program subscription gating | ✅ | limited | ✅ (premium feeds) | ✅ | | Runs on CF and AWS and GCP | ✅ | ❌ | ❌ | Firebase only | | No egress lock-in | ✅ | ❌ | varies | n/a | | HLS AES-128 key delivery | ✅ | managed | ❌ | n/a |
The differentiator is self-hosted, multi-cloud, per-episode gating with no egress fees — the paid-podcast/audio niche usually forces you onto a managed platform or a platform-specific lock.
License
Licensed under the Server Side Public License v1 (SSPL-1.0) (LICENSE).
SSPL is the license used by MongoDB. It grants the usual source-available rights (use, copy, modify, redistribute), and Section 13 adds a strong reciprocal requirement: anyone who offers the functionality of the software to third parties as a service must make the complete Service Source Code (their modifications plus the management/hosting software they use) available to everyone at no charge under SSPL.
Concretely, this means:
- Self-hosting (your own servers, your own listeners) — fine.
- Running it internally for your own product/company — fine.
- Offering it as a hosted/managed service to third parties — you must publish the full corresponding source for that service under SSPL.
This is deliberately stronger than a permissive license: it protects the licensor's managed-service offering while keeping the software open and self-hostable. SSPL-1.0 is not OSI-approved, which some organizations decline to adopt — verify that this fits your consumers before relying on it.
Commercial licensing for managed-service providers is available from the licensor.
Status
0.1.0 — extracted from the koelink production media server. Core + Cloudflare,
AWS, Google Cloud and memory adapters, examples and tests are in place.
