@secondlayer/shared 3.0.0-alpha.0 → 3.0.0-beta.2
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/dist/src/crypto/secrets.js +47 -3
- package/dist/src/crypto/secrets.js.map +5 -4
- package/dist/src/crypto/standard-webhooks.d.ts +33 -0
- package/dist/src/crypto/standard-webhooks.js +77 -0
- package/dist/src/crypto/standard-webhooks.js.map +10 -0
- package/dist/src/db/index.d.ts +74 -1
- package/dist/src/db/queries/account-spend-caps.d.ts +65 -0
- package/dist/src/db/queries/account-usage.d.ts +65 -0
- package/dist/src/db/queries/accounts.d.ts +65 -0
- package/dist/src/db/queries/integrity.d.ts +65 -0
- package/dist/src/db/queries/projects.d.ts +65 -0
- package/dist/src/db/queries/provisioning-audit.d.ts +65 -0
- package/dist/src/db/queries/subgraph-gaps.d.ts +65 -0
- package/dist/src/db/queries/subgraphs.d.ts +65 -0
- package/dist/src/db/queries/subscriptions.d.ts +475 -0
- package/dist/src/db/queries/subscriptions.js +264 -0
- package/dist/src/db/queries/subscriptions.js.map +13 -0
- package/dist/src/db/queries/tenant-compute-addons.d.ts +65 -0
- package/dist/src/db/queries/tenants.d.ts +65 -0
- package/dist/src/db/queries/tenants.js +47 -3
- package/dist/src/db/queries/tenants.js.map +5 -4
- package/dist/src/db/queries/usage.d.ts +65 -0
- package/dist/src/db/schema.d.ts +74 -1
- package/dist/src/index.d.ts +74 -1
- package/dist/src/node/local-client.d.ts +65 -0
- package/migrations/0057_subscriptions.ts +137 -0
- package/package.json +7 -11
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __returnValue = (v) => v;
|
|
4
|
+
function __exportSetter(name, newValue) {
|
|
5
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
6
|
+
}
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, {
|
|
10
|
+
get: all[name],
|
|
11
|
+
enumerable: true,
|
|
12
|
+
configurable: true,
|
|
13
|
+
set: __exportSetter.bind(all, name)
|
|
14
|
+
});
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// src/crypto/hmac.ts
|
|
18
|
+
var exports_hmac = {};
|
|
19
|
+
__export(exports_hmac, {
|
|
20
|
+
verifySignatureHeader: () => verifySignatureHeader,
|
|
21
|
+
verifySignature: () => verifySignature,
|
|
22
|
+
signPayload: () => signPayload,
|
|
23
|
+
generateSecret: () => generateSecret,
|
|
24
|
+
createSignatureHeader: () => createSignatureHeader
|
|
25
|
+
});
|
|
26
|
+
import { createHmac, randomBytes } from "crypto";
|
|
27
|
+
function generateSecret() {
|
|
28
|
+
return randomBytes(32).toString("hex");
|
|
29
|
+
}
|
|
30
|
+
function signPayload(payload, secret) {
|
|
31
|
+
const hmac = createHmac("sha256", secret);
|
|
32
|
+
hmac.update(payload);
|
|
33
|
+
return hmac.digest("hex");
|
|
34
|
+
}
|
|
35
|
+
function verifySignature(payload, signature, secret) {
|
|
36
|
+
const expectedSignature = signPayload(payload, secret);
|
|
37
|
+
if (signature.length !== expectedSignature.length) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
let result = 0;
|
|
41
|
+
for (let i = 0;i < signature.length; i++) {
|
|
42
|
+
result |= signature.charCodeAt(i) ^ expectedSignature.charCodeAt(i);
|
|
43
|
+
}
|
|
44
|
+
return result === 0;
|
|
45
|
+
}
|
|
46
|
+
function createSignatureHeader(payload, secret, timestamp) {
|
|
47
|
+
const ts = timestamp ?? Math.floor(Date.now() / 1000);
|
|
48
|
+
const signedPayload = `${ts}.${payload}`;
|
|
49
|
+
const signature = signPayload(signedPayload, secret);
|
|
50
|
+
return `t=${ts},v1=${signature}`;
|
|
51
|
+
}
|
|
52
|
+
function verifySignatureHeader(payload, header, secret, toleranceSeconds = 300) {
|
|
53
|
+
const parts = header.split(",");
|
|
54
|
+
const timestamp = parts.find((p) => p.startsWith("t="))?.slice(2);
|
|
55
|
+
const signature = parts.find((p) => p.startsWith("v1="))?.slice(3);
|
|
56
|
+
if (!timestamp || !signature) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
const ts = Number.parseInt(timestamp, 10);
|
|
60
|
+
if (isNaN(ts)) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
const now = Math.floor(Date.now() / 1000);
|
|
64
|
+
if (Math.abs(now - ts) > toleranceSeconds) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
const signedPayload = `${ts}.${payload}`;
|
|
68
|
+
return verifySignature(signedPayload, signature, secret);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// src/mode.ts
|
|
72
|
+
var VALID_MODES = ["oss", "dedicated", "platform"];
|
|
73
|
+
function getInstanceMode() {
|
|
74
|
+
const raw = process.env.INSTANCE_MODE?.trim().toLowerCase();
|
|
75
|
+
if (raw && VALID_MODES.includes(raw)) {
|
|
76
|
+
return raw;
|
|
77
|
+
}
|
|
78
|
+
return "oss";
|
|
79
|
+
}
|
|
80
|
+
function isPlatformMode() {
|
|
81
|
+
return getInstanceMode() === "platform";
|
|
82
|
+
}
|
|
83
|
+
function isOssMode() {
|
|
84
|
+
return getInstanceMode() === "oss";
|
|
85
|
+
}
|
|
86
|
+
function isDedicatedMode() {
|
|
87
|
+
return getInstanceMode() === "dedicated";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// src/crypto/secrets.ts
|
|
91
|
+
import { createCipheriv, createDecipheriv, randomBytes as randomBytes2 } from "node:crypto";
|
|
92
|
+
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
|
93
|
+
import { resolve } from "node:path";
|
|
94
|
+
var KEY_ENV = "SECONDLAYER_SECRETS_KEY";
|
|
95
|
+
var IV_LEN = 12;
|
|
96
|
+
var TAG_LEN = 16;
|
|
97
|
+
function bootstrapOssKey() {
|
|
98
|
+
const envPath = resolve(process.cwd(), ".env.local");
|
|
99
|
+
if (existsSync(envPath)) {
|
|
100
|
+
const contents = readFileSync(envPath, "utf8");
|
|
101
|
+
const match = contents.match(/^SECONDLAYER_SECRETS_KEY=([a-fA-F0-9]{64})/m);
|
|
102
|
+
if (match) {
|
|
103
|
+
process.env[KEY_ENV] = match[1];
|
|
104
|
+
return match[1];
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const hex = randomBytes2(32).toString("hex");
|
|
108
|
+
const line = `${existsSync(envPath) ? `
|
|
109
|
+
` : ""}${KEY_ENV}=${hex}
|
|
110
|
+
`;
|
|
111
|
+
appendFileSync(envPath, line, { mode: 384 });
|
|
112
|
+
process.env[KEY_ENV] = hex;
|
|
113
|
+
console.log(`[secondlayer] generated ${KEY_ENV}; saved to ${envPath} (mode 0600)`);
|
|
114
|
+
return hex;
|
|
115
|
+
}
|
|
116
|
+
function loadKey() {
|
|
117
|
+
let hex = process.env[KEY_ENV];
|
|
118
|
+
if (!hex) {
|
|
119
|
+
if (getInstanceMode() === "oss") {
|
|
120
|
+
hex = bootstrapOssKey();
|
|
121
|
+
} else {
|
|
122
|
+
throw new Error(`${KEY_ENV} not set. Generate one with: openssl rand -hex 32`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const key = Buffer.from(hex, "hex");
|
|
126
|
+
if (key.length !== 32) {
|
|
127
|
+
throw new Error(`${KEY_ENV} must be 32 bytes hex (got ${key.length})`);
|
|
128
|
+
}
|
|
129
|
+
return key;
|
|
130
|
+
}
|
|
131
|
+
var _cachedKey = null;
|
|
132
|
+
function getKey() {
|
|
133
|
+
if (!_cachedKey)
|
|
134
|
+
_cachedKey = loadKey();
|
|
135
|
+
return _cachedKey;
|
|
136
|
+
}
|
|
137
|
+
function encryptSecret(plaintext) {
|
|
138
|
+
const key = getKey();
|
|
139
|
+
const iv = randomBytes2(IV_LEN);
|
|
140
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
141
|
+
const ciphertext = Buffer.concat([
|
|
142
|
+
cipher.update(plaintext, "utf8"),
|
|
143
|
+
cipher.final()
|
|
144
|
+
]);
|
|
145
|
+
const tag = cipher.getAuthTag();
|
|
146
|
+
return Buffer.concat([iv, tag, ciphertext]);
|
|
147
|
+
}
|
|
148
|
+
function decryptSecret(envelope) {
|
|
149
|
+
const key = getKey();
|
|
150
|
+
const iv = envelope.subarray(0, IV_LEN);
|
|
151
|
+
const tag = envelope.subarray(IV_LEN, IV_LEN + TAG_LEN);
|
|
152
|
+
const ciphertext = envelope.subarray(IV_LEN + TAG_LEN);
|
|
153
|
+
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
|
154
|
+
decipher.setAuthTag(tag);
|
|
155
|
+
return decipher.update(ciphertext).toString("utf8") + decipher.final("utf8");
|
|
156
|
+
}
|
|
157
|
+
function generateSecretsKey() {
|
|
158
|
+
return randomBytes2(32).toString("hex");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// src/db/queries/subscriptions.ts
|
|
162
|
+
import { sql } from "kysely";
|
|
163
|
+
async function createSubscription(db, input) {
|
|
164
|
+
const signingSecret = generateSecret();
|
|
165
|
+
const row = {
|
|
166
|
+
account_id: input.accountId,
|
|
167
|
+
project_id: input.projectId ?? null,
|
|
168
|
+
name: input.name,
|
|
169
|
+
status: "active",
|
|
170
|
+
subgraph_name: input.subgraphName,
|
|
171
|
+
table_name: input.tableName,
|
|
172
|
+
filter: input.filter ?? {},
|
|
173
|
+
format: input.format ?? "standard-webhooks",
|
|
174
|
+
runtime: input.runtime ?? null,
|
|
175
|
+
url: input.url,
|
|
176
|
+
signing_secret_enc: encryptSecret(signingSecret),
|
|
177
|
+
auth_config: input.authConfig ?? {},
|
|
178
|
+
...input.maxRetries !== undefined ? { max_retries: input.maxRetries } : {},
|
|
179
|
+
...input.timeoutMs !== undefined ? { timeout_ms: input.timeoutMs } : {},
|
|
180
|
+
...input.concurrency !== undefined ? { concurrency: input.concurrency } : {}
|
|
181
|
+
};
|
|
182
|
+
const subscription = await db.insertInto("subscriptions").values(row).returningAll().executeTakeFirstOrThrow();
|
|
183
|
+
return { subscription, signingSecret };
|
|
184
|
+
}
|
|
185
|
+
async function listSubscriptions(db, accountId) {
|
|
186
|
+
return db.selectFrom("subscriptions").selectAll().where("account_id", "=", accountId).orderBy("created_at", "desc").execute();
|
|
187
|
+
}
|
|
188
|
+
async function getSubscription(db, accountId, id) {
|
|
189
|
+
const row = await db.selectFrom("subscriptions").selectAll().where("account_id", "=", accountId).where("id", "=", id).executeTakeFirst();
|
|
190
|
+
return row ?? null;
|
|
191
|
+
}
|
|
192
|
+
async function getSubscriptionByName(db, accountId, name) {
|
|
193
|
+
const row = await db.selectFrom("subscriptions").selectAll().where("account_id", "=", accountId).where("name", "=", name).executeTakeFirst();
|
|
194
|
+
return row ?? null;
|
|
195
|
+
}
|
|
196
|
+
async function updateSubscription(db, accountId, id, patch) {
|
|
197
|
+
const update = { updated_at: new Date };
|
|
198
|
+
if (patch.name !== undefined)
|
|
199
|
+
update.name = patch.name;
|
|
200
|
+
if (patch.filter !== undefined)
|
|
201
|
+
update.filter = patch.filter;
|
|
202
|
+
if (patch.format !== undefined)
|
|
203
|
+
update.format = patch.format;
|
|
204
|
+
if (patch.runtime !== undefined)
|
|
205
|
+
update.runtime = patch.runtime;
|
|
206
|
+
if (patch.url !== undefined)
|
|
207
|
+
update.url = patch.url;
|
|
208
|
+
if (patch.authConfig !== undefined)
|
|
209
|
+
update.auth_config = patch.authConfig;
|
|
210
|
+
if (patch.maxRetries !== undefined)
|
|
211
|
+
update.max_retries = patch.maxRetries;
|
|
212
|
+
if (patch.timeoutMs !== undefined)
|
|
213
|
+
update.timeout_ms = patch.timeoutMs;
|
|
214
|
+
if (patch.concurrency !== undefined)
|
|
215
|
+
update.concurrency = patch.concurrency;
|
|
216
|
+
const row = await db.updateTable("subscriptions").set(update).where("account_id", "=", accountId).where("id", "=", id).returningAll().executeTakeFirst();
|
|
217
|
+
return row ?? null;
|
|
218
|
+
}
|
|
219
|
+
async function toggleSubscriptionStatus(db, accountId, id, status) {
|
|
220
|
+
const row = await db.updateTable("subscriptions").set({
|
|
221
|
+
status,
|
|
222
|
+
updated_at: new Date,
|
|
223
|
+
...status === "active" ? {
|
|
224
|
+
circuit_failures: 0,
|
|
225
|
+
circuit_opened_at: null
|
|
226
|
+
} : {}
|
|
227
|
+
}).where("account_id", "=", accountId).where("id", "=", id).returningAll().executeTakeFirst();
|
|
228
|
+
return row ?? null;
|
|
229
|
+
}
|
|
230
|
+
async function deleteSubscription(db, accountId, id) {
|
|
231
|
+
const res = await db.deleteFrom("subscriptions").where("account_id", "=", accountId).where("id", "=", id).executeTakeFirst();
|
|
232
|
+
return Number(res.numDeletedRows ?? 0) > 0;
|
|
233
|
+
}
|
|
234
|
+
async function rotateSubscriptionSecret(db, accountId, id) {
|
|
235
|
+
const signingSecret = generateSecret();
|
|
236
|
+
const row = await db.updateTable("subscriptions").set({
|
|
237
|
+
signing_secret_enc: encryptSecret(signingSecret),
|
|
238
|
+
updated_at: new Date
|
|
239
|
+
}).where("account_id", "=", accountId).where("id", "=", id).returningAll().executeTakeFirst();
|
|
240
|
+
if (!row)
|
|
241
|
+
return null;
|
|
242
|
+
return { subscription: row, signingSecret };
|
|
243
|
+
}
|
|
244
|
+
function getSubscriptionSigningSecret(sub) {
|
|
245
|
+
return decryptSecret(sub.signing_secret_enc);
|
|
246
|
+
}
|
|
247
|
+
async function notifySubscriptionsChanged(db, accountId) {
|
|
248
|
+
await sql`SELECT pg_notify('subscriptions:changed', ${accountId})`.execute(db);
|
|
249
|
+
}
|
|
250
|
+
export {
|
|
251
|
+
updateSubscription,
|
|
252
|
+
toggleSubscriptionStatus,
|
|
253
|
+
rotateSubscriptionSecret,
|
|
254
|
+
notifySubscriptionsChanged,
|
|
255
|
+
listSubscriptions,
|
|
256
|
+
getSubscriptionSigningSecret,
|
|
257
|
+
getSubscriptionByName,
|
|
258
|
+
getSubscription,
|
|
259
|
+
deleteSubscription,
|
|
260
|
+
createSubscription
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
//# debugId=6C6C97A09D12DAB864756E2164756E21
|
|
264
|
+
//# sourceMappingURL=subscriptions.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/crypto/hmac.ts", "../src/mode.ts", "../src/crypto/secrets.ts", "../src/db/queries/subscriptions.ts"],
|
|
4
|
+
"sourcesContent": [
|
|
5
|
+
"import { createHmac, randomBytes } from \"crypto\";\n\n/**\n * Generate a random secret for delivery signing\n * Returns 32 bytes as a 64-character hex string\n */\nexport function generateSecret(): string {\n\treturn randomBytes(32).toString(\"hex\");\n}\n\n/**\n * Sign a payload with HMAC-SHA256\n * Returns the signature as a hex string\n */\nexport function signPayload(payload: string, secret: string): string {\n\tconst hmac = createHmac(\"sha256\", secret);\n\thmac.update(payload);\n\treturn hmac.digest(\"hex\");\n}\n\n/**\n * Verify an HMAC signature\n * Uses constant-time comparison to prevent timing attacks\n */\nexport function verifySignature(\n\tpayload: string,\n\tsignature: string,\n\tsecret: string,\n): boolean {\n\tconst expectedSignature = signPayload(payload, secret);\n\n\t// Constant-time comparison\n\tif (signature.length !== expectedSignature.length) {\n\t\treturn false;\n\t}\n\n\tlet result = 0;\n\tfor (let i = 0; i < signature.length; i++) {\n\t\tresult |= signature.charCodeAt(i) ^ expectedSignature.charCodeAt(i);\n\t}\n\n\treturn result === 0;\n}\n\n/**\n * Create a Stripe-style signature header\n * Format: t=timestamp,v1=signature\n */\nexport function createSignatureHeader(\n\tpayload: string,\n\tsecret: string,\n\ttimestamp?: number,\n): string {\n\tconst ts = timestamp ?? Math.floor(Date.now() / 1000);\n\tconst signedPayload = `${ts}.${payload}`;\n\tconst signature = signPayload(signedPayload, secret);\n\n\treturn `t=${ts},v1=${signature}`;\n}\n\n/**\n * Parse and verify a Stripe-style signature header\n * Returns true if valid, false otherwise\n */\nexport function verifySignatureHeader(\n\tpayload: string,\n\theader: string,\n\tsecret: string,\n\ttoleranceSeconds = 300, // 5 minutes\n): boolean {\n\t// Parse header\n\tconst parts = header.split(\",\");\n\tconst timestamp = parts.find((p) => p.startsWith(\"t=\"))?.slice(2);\n\tconst signature = parts.find((p) => p.startsWith(\"v1=\"))?.slice(3);\n\n\tif (!timestamp || !signature) {\n\t\treturn false;\n\t}\n\n\tconst ts = Number.parseInt(timestamp, 10);\n\tif (isNaN(ts)) {\n\t\treturn false;\n\t}\n\n\t// Check timestamp is within tolerance\n\tconst now = Math.floor(Date.now() / 1000);\n\tif (Math.abs(now - ts) > toleranceSeconds) {\n\t\treturn false;\n\t}\n\n\t// Verify signature\n\tconst signedPayload = `${ts}.${payload}`;\n\treturn verifySignature(signedPayload, signature, secret);\n}\n",
|
|
6
|
+
"/**\n * Instance modes for the Secondlayer platform.\n *\n * - `oss`: self-hosted, single-tenant. No auth middleware, no platform routes\n * (projects, admin, workflows). Everything runs against a single\n * `DATABASE_URL`. Intended for `docker compose up`.\n *\n * - `dedicated`: per-customer managed instance. JWT-based auth (anon =\n * read-only, service = full). Dual-DB mode — shared source indexer DB for\n * block reads, per-tenant target DB for subgraph data. No platform-wide\n * routes mounted (no cross-tenant accounts).\n *\n * - `platform`: control-plane mode. Magic-link auth, API keys, projects,\n * tenants, admin. Serves the dashboard + CLI against a single shared DB.\n */\n\nexport type InstanceMode = \"oss\" | \"dedicated\" | \"platform\";\n\nconst VALID_MODES: readonly InstanceMode[] = [\"oss\", \"dedicated\", \"platform\"];\n\n/**\n * Resolve the active instance mode from `process.env.INSTANCE_MODE`.\n * Defaults to `\"oss\"` — the safest default for self-hosters who deploy\n * without setting the variable.\n */\nexport function getInstanceMode(): InstanceMode {\n\tconst raw = process.env.INSTANCE_MODE?.trim().toLowerCase();\n\tif (raw && (VALID_MODES as readonly string[]).includes(raw)) {\n\t\treturn raw as InstanceMode;\n\t}\n\treturn \"oss\";\n}\n\n/** True when the active mode is `\"platform\"` (shared multi-tenant). */\nexport function isPlatformMode(): boolean {\n\treturn getInstanceMode() === \"platform\";\n}\n\n/** True when the active mode is `\"oss\"` (self-hosted). */\nexport function isOssMode(): boolean {\n\treturn getInstanceMode() === \"oss\";\n}\n\n/** True when the active mode is `\"dedicated\"` (per-tenant managed). */\nexport function isDedicatedMode(): boolean {\n\treturn getInstanceMode() === \"dedicated\";\n}\n",
|
|
7
|
+
"import { createCipheriv, createDecipheriv, randomBytes } from \"node:crypto\";\nimport { appendFileSync, existsSync, readFileSync } from \"node:fs\";\nimport { resolve } from \"node:path\";\nimport { getInstanceMode } from \"../mode.ts\";\n\n/**\n * AES-256-GCM symmetric envelope for encrypted secrets at rest (tenant keys,\n * subscription signing secrets, etc.).\n *\n * Ciphertext layout: `iv (12 bytes) || authTag (16 bytes) || ciphertext`\n *\n * The key comes from `SECONDLAYER_SECRETS_KEY` — 32 bytes hex. In OSS mode,\n * if the env var is unset on first use we autogenerate a key and persist it\n * to `.env.local` in the current working directory so subsequent restarts\n * pick it up without user intervention. Dedicated/platform modes throw —\n * those runtimes must provision the key explicitly.\n *\n * Rotation strategy: re-encrypt all rows with the new key and swap the env\n * var. Not zero-downtime, but acceptable at v2 scale. For real KMS (AWS\n * KMS, Vault, GCP KMS), wrap the same byte layout behind an\n * `EncryptSecret`/`DecryptSecret` interface and swap at startup.\n */\n\nconst KEY_ENV = \"SECONDLAYER_SECRETS_KEY\";\nconst IV_LEN = 12;\nconst TAG_LEN = 16;\n\nfunction bootstrapOssKey(): string {\n\tconst envPath = resolve(process.cwd(), \".env.local\");\n\n\t// Check existing .env.local first — prior run may have written it.\n\tif (existsSync(envPath)) {\n\t\tconst contents = readFileSync(envPath, \"utf8\");\n\t\tconst match = contents.match(/^SECONDLAYER_SECRETS_KEY=([a-fA-F0-9]{64})/m);\n\t\tif (match) {\n\t\t\tprocess.env[KEY_ENV] = match[1];\n\t\t\treturn match[1];\n\t\t}\n\t}\n\n\tconst hex = randomBytes(32).toString(\"hex\");\n\tconst line = `${existsSync(envPath) ? \"\\n\" : \"\"}${KEY_ENV}=${hex}\\n`;\n\tappendFileSync(envPath, line, { mode: 0o600 });\n\tprocess.env[KEY_ENV] = hex;\n\tconsole.log(\n\t\t`[secondlayer] generated ${KEY_ENV}; saved to ${envPath} (mode 0600)`,\n\t);\n\treturn hex;\n}\n\nfunction loadKey(): Buffer {\n\tlet hex = process.env[KEY_ENV];\n\tif (!hex) {\n\t\tif (getInstanceMode() === \"oss\") {\n\t\t\thex = bootstrapOssKey();\n\t\t} else {\n\t\t\tthrow new Error(\n\t\t\t\t`${KEY_ENV} not set. Generate one with: openssl rand -hex 32`,\n\t\t\t);\n\t\t}\n\t}\n\tconst key = Buffer.from(hex, \"hex\");\n\tif (key.length !== 32) {\n\t\tthrow new Error(`${KEY_ENV} must be 32 bytes hex (got ${key.length})`);\n\t}\n\treturn key;\n}\n\nlet _cachedKey: Buffer | null = null;\nfunction getKey(): Buffer {\n\tif (!_cachedKey) _cachedKey = loadKey();\n\treturn _cachedKey;\n}\n\nexport function encryptSecret(plaintext: string): Buffer {\n\tconst key = getKey();\n\tconst iv = randomBytes(IV_LEN);\n\tconst cipher = createCipheriv(\"aes-256-gcm\", key, iv);\n\tconst ciphertext = Buffer.concat([\n\t\tcipher.update(plaintext, \"utf8\"),\n\t\tcipher.final(),\n\t]);\n\tconst tag = cipher.getAuthTag();\n\treturn Buffer.concat([iv, tag, ciphertext]);\n}\n\nexport function decryptSecret(envelope: Buffer): string {\n\tconst key = getKey();\n\tconst iv = envelope.subarray(0, IV_LEN);\n\tconst tag = envelope.subarray(IV_LEN, IV_LEN + TAG_LEN);\n\tconst ciphertext = envelope.subarray(IV_LEN + TAG_LEN);\n\tconst decipher = createDecipheriv(\"aes-256-gcm\", key, iv);\n\tdecipher.setAuthTag(tag);\n\treturn decipher.update(ciphertext).toString(\"utf8\") + decipher.final(\"utf8\");\n}\n\n/** Generate a fresh 32-byte hex key suitable for `SECONDLAYER_SECRETS_KEY`. */\nexport function generateSecretsKey(): string {\n\treturn randomBytes(32).toString(\"hex\");\n}\n",
|
|
8
|
+
"import { type Kysely, sql } from \"kysely\";\nimport { generateSecret } from \"../../crypto/hmac.ts\";\nimport { decryptSecret, encryptSecret } from \"../../crypto/secrets.ts\";\nimport type {\n\tDatabase,\n\tInsertSubscription,\n\tSubscription,\n\tSubscriptionFormat,\n\tSubscriptionRuntime,\n\tSubscriptionStatus,\n\tUpdateSubscription,\n} from \"../types.ts\";\n\n/**\n * Subscription CRUD. `signing_secret_enc` is transparently encrypted via\n * `encryptSecret`/`decryptSecret`. Plaintext secrets only leave via the\n * return value of `create` (one-time display) and `rotateSecret`.\n */\n\nexport interface CreateSubscriptionInput {\n\taccountId: string;\n\tprojectId?: string | null;\n\tname: string;\n\tsubgraphName: string;\n\ttableName: string;\n\tfilter?: unknown;\n\tformat?: SubscriptionFormat;\n\truntime?: SubscriptionRuntime | null;\n\turl: string;\n\tauthConfig?: unknown;\n\tmaxRetries?: number;\n\ttimeoutMs?: number;\n\tconcurrency?: number;\n}\n\nexport interface CreateSubscriptionResult {\n\tsubscription: Subscription;\n\t/** Plaintext signing secret — surfaced once, never stored decrypted. */\n\tsigningSecret: string;\n}\n\nexport async function createSubscription(\n\tdb: Kysely<Database>,\n\tinput: CreateSubscriptionInput,\n): Promise<CreateSubscriptionResult> {\n\tconst signingSecret = generateSecret();\n\tconst row: InsertSubscription = {\n\t\taccount_id: input.accountId,\n\t\tproject_id: input.projectId ?? null,\n\t\tname: input.name,\n\t\tstatus: \"active\",\n\t\tsubgraph_name: input.subgraphName,\n\t\ttable_name: input.tableName,\n\t\tfilter: input.filter ?? {},\n\t\tformat: input.format ?? \"standard-webhooks\",\n\t\truntime: input.runtime ?? null,\n\t\turl: input.url,\n\t\tsigning_secret_enc: encryptSecret(signingSecret),\n\t\tauth_config: input.authConfig ?? {},\n\t\t...(input.maxRetries !== undefined ? { max_retries: input.maxRetries } : {}),\n\t\t...(input.timeoutMs !== undefined ? { timeout_ms: input.timeoutMs } : {}),\n\t\t...(input.concurrency !== undefined\n\t\t\t? { concurrency: input.concurrency }\n\t\t\t: {}),\n\t};\n\tconst subscription = await db\n\t\t.insertInto(\"subscriptions\")\n\t\t.values(row)\n\t\t.returningAll()\n\t\t.executeTakeFirstOrThrow();\n\treturn { subscription, signingSecret };\n}\n\nexport async function listSubscriptions(\n\tdb: Kysely<Database>,\n\taccountId: string,\n): Promise<Subscription[]> {\n\treturn db\n\t\t.selectFrom(\"subscriptions\")\n\t\t.selectAll()\n\t\t.where(\"account_id\", \"=\", accountId)\n\t\t.orderBy(\"created_at\", \"desc\")\n\t\t.execute();\n}\n\nexport async function getSubscription(\n\tdb: Kysely<Database>,\n\taccountId: string,\n\tid: string,\n): Promise<Subscription | null> {\n\tconst row = await db\n\t\t.selectFrom(\"subscriptions\")\n\t\t.selectAll()\n\t\t.where(\"account_id\", \"=\", accountId)\n\t\t.where(\"id\", \"=\", id)\n\t\t.executeTakeFirst();\n\treturn row ?? null;\n}\n\nexport async function getSubscriptionByName(\n\tdb: Kysely<Database>,\n\taccountId: string,\n\tname: string,\n): Promise<Subscription | null> {\n\tconst row = await db\n\t\t.selectFrom(\"subscriptions\")\n\t\t.selectAll()\n\t\t.where(\"account_id\", \"=\", accountId)\n\t\t.where(\"name\", \"=\", name)\n\t\t.executeTakeFirst();\n\treturn row ?? null;\n}\n\nexport interface UpdateSubscriptionInput {\n\tname?: string;\n\tfilter?: unknown;\n\tformat?: SubscriptionFormat;\n\truntime?: SubscriptionRuntime | null;\n\turl?: string;\n\tauthConfig?: unknown;\n\tmaxRetries?: number;\n\ttimeoutMs?: number;\n\tconcurrency?: number;\n}\n\nexport async function updateSubscription(\n\tdb: Kysely<Database>,\n\taccountId: string,\n\tid: string,\n\tpatch: UpdateSubscriptionInput,\n): Promise<Subscription | null> {\n\tconst update: UpdateSubscription = { updated_at: new Date() };\n\tif (patch.name !== undefined) update.name = patch.name;\n\tif (patch.filter !== undefined) update.filter = patch.filter;\n\tif (patch.format !== undefined) update.format = patch.format;\n\tif (patch.runtime !== undefined) update.runtime = patch.runtime;\n\tif (patch.url !== undefined) update.url = patch.url;\n\tif (patch.authConfig !== undefined) update.auth_config = patch.authConfig;\n\tif (patch.maxRetries !== undefined) update.max_retries = patch.maxRetries;\n\tif (patch.timeoutMs !== undefined) update.timeout_ms = patch.timeoutMs;\n\tif (patch.concurrency !== undefined) update.concurrency = patch.concurrency;\n\n\tconst row = await db\n\t\t.updateTable(\"subscriptions\")\n\t\t.set(update)\n\t\t.where(\"account_id\", \"=\", accountId)\n\t\t.where(\"id\", \"=\", id)\n\t\t.returningAll()\n\t\t.executeTakeFirst();\n\treturn row ?? null;\n}\n\nexport async function toggleSubscriptionStatus(\n\tdb: Kysely<Database>,\n\taccountId: string,\n\tid: string,\n\tstatus: SubscriptionStatus,\n): Promise<Subscription | null> {\n\tconst row = await db\n\t\t.updateTable(\"subscriptions\")\n\t\t.set({\n\t\t\tstatus,\n\t\t\tupdated_at: new Date(),\n\t\t\t...(status === \"active\"\n\t\t\t\t? {\n\t\t\t\t\t\tcircuit_failures: 0,\n\t\t\t\t\t\tcircuit_opened_at: null,\n\t\t\t\t\t}\n\t\t\t\t: {}),\n\t\t})\n\t\t.where(\"account_id\", \"=\", accountId)\n\t\t.where(\"id\", \"=\", id)\n\t\t.returningAll()\n\t\t.executeTakeFirst();\n\treturn row ?? null;\n}\n\nexport async function deleteSubscription(\n\tdb: Kysely<Database>,\n\taccountId: string,\n\tid: string,\n): Promise<boolean> {\n\tconst res = await db\n\t\t.deleteFrom(\"subscriptions\")\n\t\t.where(\"account_id\", \"=\", accountId)\n\t\t.where(\"id\", \"=\", id)\n\t\t.executeTakeFirst();\n\treturn Number(res.numDeletedRows ?? 0) > 0;\n}\n\nexport interface RotateSecretResult {\n\tsubscription: Subscription;\n\tsigningSecret: string;\n}\n\nexport async function rotateSubscriptionSecret(\n\tdb: Kysely<Database>,\n\taccountId: string,\n\tid: string,\n): Promise<RotateSecretResult | null> {\n\tconst signingSecret = generateSecret();\n\tconst row = await db\n\t\t.updateTable(\"subscriptions\")\n\t\t.set({\n\t\t\tsigning_secret_enc: encryptSecret(signingSecret),\n\t\t\tupdated_at: new Date(),\n\t\t})\n\t\t.where(\"account_id\", \"=\", accountId)\n\t\t.where(\"id\", \"=\", id)\n\t\t.returningAll()\n\t\t.executeTakeFirst();\n\tif (!row) return null;\n\treturn { subscription: row, signingSecret };\n}\n\n/** Decrypt a subscription's signing secret for HMAC signing at emit time. */\nexport function getSubscriptionSigningSecret(sub: Subscription): string {\n\treturn decryptSecret(sub.signing_secret_enc);\n}\n\n/** Fire `subscriptions:changed` notify so the emitter hot-reloads its cache. */\nexport async function notifySubscriptionsChanged(\n\tdb: Kysely<Database>,\n\taccountId: string,\n): Promise<void> {\n\tawait sql`SELECT pg_notify('subscriptions:changed', ${accountId})`.execute(db);\n}\n"
|
|
9
|
+
],
|
|
10
|
+
"mappings": "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",
|
|
11
|
+
"debugId": "6C6C97A09D12DAB864756E2164756E21",
|
|
12
|
+
"names": []
|
|
13
|
+
}
|
|
@@ -277,6 +277,9 @@ interface Database {
|
|
|
277
277
|
tenant_compute_addons: TenantComputeAddonsTable;
|
|
278
278
|
account_spend_caps: AccountSpendCapsTable;
|
|
279
279
|
provisioning_audit_log: ProvisioningAuditLogTable;
|
|
280
|
+
subscriptions: SubscriptionsTable;
|
|
281
|
+
subscription_outbox: SubscriptionOutboxTable;
|
|
282
|
+
subscription_deliveries: SubscriptionDeliveriesTable;
|
|
280
283
|
}
|
|
281
284
|
type TenantStatus = "provisioning" | "active" | "suspended" | "error" | "deleted";
|
|
282
285
|
interface TenantsTable {
|
|
@@ -355,6 +358,68 @@ interface ProvisioningAuditLogTable {
|
|
|
355
358
|
error: string | null;
|
|
356
359
|
created_at: Generated<Date>;
|
|
357
360
|
}
|
|
361
|
+
type SubscriptionStatus = "active" | "paused" | "error";
|
|
362
|
+
type SubscriptionFormat = "standard-webhooks" | "inngest" | "trigger" | "cloudflare" | "cloudevents" | "raw";
|
|
363
|
+
type SubscriptionRuntime = "inngest" | "trigger" | "cloudflare" | "node";
|
|
364
|
+
interface SubscriptionsTable {
|
|
365
|
+
id: Generated<string>;
|
|
366
|
+
account_id: string;
|
|
367
|
+
project_id: string | null;
|
|
368
|
+
name: string;
|
|
369
|
+
status: ColumnType<SubscriptionStatus, SubscriptionStatus | undefined, SubscriptionStatus>;
|
|
370
|
+
subgraph_name: string;
|
|
371
|
+
table_name: string;
|
|
372
|
+
filter: Generated<unknown>;
|
|
373
|
+
format: ColumnType<SubscriptionFormat, SubscriptionFormat | undefined, SubscriptionFormat>;
|
|
374
|
+
runtime: SubscriptionRuntime | null;
|
|
375
|
+
url: string;
|
|
376
|
+
signing_secret_enc: Buffer;
|
|
377
|
+
auth_config: Generated<unknown>;
|
|
378
|
+
max_retries: Generated<number>;
|
|
379
|
+
timeout_ms: Generated<number>;
|
|
380
|
+
concurrency: Generated<number>;
|
|
381
|
+
circuit_failures: Generated<number>;
|
|
382
|
+
circuit_opened_at: Date | null;
|
|
383
|
+
last_delivery_at: Date | null;
|
|
384
|
+
last_success_at: Date | null;
|
|
385
|
+
last_error: string | null;
|
|
386
|
+
created_at: Generated<Date>;
|
|
387
|
+
updated_at: Generated<Date>;
|
|
388
|
+
}
|
|
389
|
+
type OutboxStatus = "pending" | "delivered" | "dead";
|
|
390
|
+
interface SubscriptionOutboxTable {
|
|
391
|
+
id: Generated<string>;
|
|
392
|
+
subscription_id: string;
|
|
393
|
+
subgraph_name: string;
|
|
394
|
+
table_name: string;
|
|
395
|
+
block_height: number | bigint;
|
|
396
|
+
tx_id: string | null;
|
|
397
|
+
row_pk: unknown;
|
|
398
|
+
event_type: string;
|
|
399
|
+
payload: unknown;
|
|
400
|
+
dedup_key: string;
|
|
401
|
+
attempt: Generated<number>;
|
|
402
|
+
next_attempt_at: Generated<Date>;
|
|
403
|
+
status: ColumnType<OutboxStatus, OutboxStatus | undefined, OutboxStatus>;
|
|
404
|
+
is_replay: Generated<boolean>;
|
|
405
|
+
delivered_at: Date | null;
|
|
406
|
+
failed_at: Date | null;
|
|
407
|
+
locked_by: string | null;
|
|
408
|
+
locked_until: Date | null;
|
|
409
|
+
created_at: Generated<Date>;
|
|
410
|
+
}
|
|
411
|
+
interface SubscriptionDeliveriesTable {
|
|
412
|
+
id: Generated<string>;
|
|
413
|
+
outbox_id: string;
|
|
414
|
+
subscription_id: string;
|
|
415
|
+
attempt: number;
|
|
416
|
+
status_code: number | null;
|
|
417
|
+
response_headers: unknown | null;
|
|
418
|
+
response_body: string | null;
|
|
419
|
+
error_message: string | null;
|
|
420
|
+
duration_ms: number | null;
|
|
421
|
+
dispatched_at: Generated<Date>;
|
|
422
|
+
}
|
|
358
423
|
/**
|
|
359
424
|
* Compute add-ons — extras on top of a plan's base spec.
|
|
360
425
|
*
|
|
@@ -277,6 +277,9 @@ interface Database {
|
|
|
277
277
|
tenant_compute_addons: TenantComputeAddonsTable;
|
|
278
278
|
account_spend_caps: AccountSpendCapsTable;
|
|
279
279
|
provisioning_audit_log: ProvisioningAuditLogTable;
|
|
280
|
+
subscriptions: SubscriptionsTable;
|
|
281
|
+
subscription_outbox: SubscriptionOutboxTable;
|
|
282
|
+
subscription_deliveries: SubscriptionDeliveriesTable;
|
|
280
283
|
}
|
|
281
284
|
type TenantStatus = "provisioning" | "active" | "suspended" | "error" | "deleted";
|
|
282
285
|
interface TenantsTable {
|
|
@@ -355,6 +358,68 @@ interface ProvisioningAuditLogTable {
|
|
|
355
358
|
error: string | null;
|
|
356
359
|
created_at: Generated<Date>;
|
|
357
360
|
}
|
|
361
|
+
type SubscriptionStatus = "active" | "paused" | "error";
|
|
362
|
+
type SubscriptionFormat = "standard-webhooks" | "inngest" | "trigger" | "cloudflare" | "cloudevents" | "raw";
|
|
363
|
+
type SubscriptionRuntime = "inngest" | "trigger" | "cloudflare" | "node";
|
|
364
|
+
interface SubscriptionsTable {
|
|
365
|
+
id: Generated<string>;
|
|
366
|
+
account_id: string;
|
|
367
|
+
project_id: string | null;
|
|
368
|
+
name: string;
|
|
369
|
+
status: ColumnType<SubscriptionStatus, SubscriptionStatus | undefined, SubscriptionStatus>;
|
|
370
|
+
subgraph_name: string;
|
|
371
|
+
table_name: string;
|
|
372
|
+
filter: Generated<unknown>;
|
|
373
|
+
format: ColumnType<SubscriptionFormat, SubscriptionFormat | undefined, SubscriptionFormat>;
|
|
374
|
+
runtime: SubscriptionRuntime | null;
|
|
375
|
+
url: string;
|
|
376
|
+
signing_secret_enc: Buffer;
|
|
377
|
+
auth_config: Generated<unknown>;
|
|
378
|
+
max_retries: Generated<number>;
|
|
379
|
+
timeout_ms: Generated<number>;
|
|
380
|
+
concurrency: Generated<number>;
|
|
381
|
+
circuit_failures: Generated<number>;
|
|
382
|
+
circuit_opened_at: Date | null;
|
|
383
|
+
last_delivery_at: Date | null;
|
|
384
|
+
last_success_at: Date | null;
|
|
385
|
+
last_error: string | null;
|
|
386
|
+
created_at: Generated<Date>;
|
|
387
|
+
updated_at: Generated<Date>;
|
|
388
|
+
}
|
|
389
|
+
type OutboxStatus = "pending" | "delivered" | "dead";
|
|
390
|
+
interface SubscriptionOutboxTable {
|
|
391
|
+
id: Generated<string>;
|
|
392
|
+
subscription_id: string;
|
|
393
|
+
subgraph_name: string;
|
|
394
|
+
table_name: string;
|
|
395
|
+
block_height: number | bigint;
|
|
396
|
+
tx_id: string | null;
|
|
397
|
+
row_pk: unknown;
|
|
398
|
+
event_type: string;
|
|
399
|
+
payload: unknown;
|
|
400
|
+
dedup_key: string;
|
|
401
|
+
attempt: Generated<number>;
|
|
402
|
+
next_attempt_at: Generated<Date>;
|
|
403
|
+
status: ColumnType<OutboxStatus, OutboxStatus | undefined, OutboxStatus>;
|
|
404
|
+
is_replay: Generated<boolean>;
|
|
405
|
+
delivered_at: Date | null;
|
|
406
|
+
failed_at: Date | null;
|
|
407
|
+
locked_by: string | null;
|
|
408
|
+
locked_until: Date | null;
|
|
409
|
+
created_at: Generated<Date>;
|
|
410
|
+
}
|
|
411
|
+
interface SubscriptionDeliveriesTable {
|
|
412
|
+
id: Generated<string>;
|
|
413
|
+
outbox_id: string;
|
|
414
|
+
subscription_id: string;
|
|
415
|
+
attempt: number;
|
|
416
|
+
status_code: number | null;
|
|
417
|
+
response_headers: unknown | null;
|
|
418
|
+
response_body: string | null;
|
|
419
|
+
error_message: string | null;
|
|
420
|
+
duration_ms: number | null;
|
|
421
|
+
dispatched_at: Generated<Date>;
|
|
422
|
+
}
|
|
358
423
|
/**
|
|
359
424
|
* Tenant registry queries. Encrypted columns are stored as `bytea` and
|
|
360
425
|
* transparently encrypted/decrypted via `encryptSecret`/`decryptSecret`.
|
|
@@ -14,15 +14,59 @@ var __export = (target, all) => {
|
|
|
14
14
|
});
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
+
// src/mode.ts
|
|
18
|
+
var VALID_MODES = ["oss", "dedicated", "platform"];
|
|
19
|
+
function getInstanceMode() {
|
|
20
|
+
const raw = process.env.INSTANCE_MODE?.trim().toLowerCase();
|
|
21
|
+
if (raw && VALID_MODES.includes(raw)) {
|
|
22
|
+
return raw;
|
|
23
|
+
}
|
|
24
|
+
return "oss";
|
|
25
|
+
}
|
|
26
|
+
function isPlatformMode() {
|
|
27
|
+
return getInstanceMode() === "platform";
|
|
28
|
+
}
|
|
29
|
+
function isOssMode() {
|
|
30
|
+
return getInstanceMode() === "oss";
|
|
31
|
+
}
|
|
32
|
+
function isDedicatedMode() {
|
|
33
|
+
return getInstanceMode() === "dedicated";
|
|
34
|
+
}
|
|
35
|
+
|
|
17
36
|
// src/crypto/secrets.ts
|
|
18
37
|
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
38
|
+
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
|
39
|
+
import { resolve } from "node:path";
|
|
19
40
|
var KEY_ENV = "SECONDLAYER_SECRETS_KEY";
|
|
20
41
|
var IV_LEN = 12;
|
|
21
42
|
var TAG_LEN = 16;
|
|
43
|
+
function bootstrapOssKey() {
|
|
44
|
+
const envPath = resolve(process.cwd(), ".env.local");
|
|
45
|
+
if (existsSync(envPath)) {
|
|
46
|
+
const contents = readFileSync(envPath, "utf8");
|
|
47
|
+
const match = contents.match(/^SECONDLAYER_SECRETS_KEY=([a-fA-F0-9]{64})/m);
|
|
48
|
+
if (match) {
|
|
49
|
+
process.env[KEY_ENV] = match[1];
|
|
50
|
+
return match[1];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const hex = randomBytes(32).toString("hex");
|
|
54
|
+
const line = `${existsSync(envPath) ? `
|
|
55
|
+
` : ""}${KEY_ENV}=${hex}
|
|
56
|
+
`;
|
|
57
|
+
appendFileSync(envPath, line, { mode: 384 });
|
|
58
|
+
process.env[KEY_ENV] = hex;
|
|
59
|
+
console.log(`[secondlayer] generated ${KEY_ENV}; saved to ${envPath} (mode 0600)`);
|
|
60
|
+
return hex;
|
|
61
|
+
}
|
|
22
62
|
function loadKey() {
|
|
23
|
-
|
|
63
|
+
let hex = process.env[KEY_ENV];
|
|
24
64
|
if (!hex) {
|
|
25
|
-
|
|
65
|
+
if (getInstanceMode() === "oss") {
|
|
66
|
+
hex = bootstrapOssKey();
|
|
67
|
+
} else {
|
|
68
|
+
throw new Error(`${KEY_ENV} not set. Generate one with: openssl rand -hex 32`);
|
|
69
|
+
}
|
|
26
70
|
}
|
|
27
71
|
const key = Buffer.from(hex, "hex");
|
|
28
72
|
if (key.length !== 32) {
|
|
@@ -219,5 +263,5 @@ export {
|
|
|
219
263
|
bumpTenantActivity
|
|
220
264
|
};
|
|
221
265
|
|
|
222
|
-
//# debugId=
|
|
266
|
+
//# debugId=9AAE380FD6880D8264756E2164756E21
|
|
223
267
|
//# sourceMappingURL=tenants.js.map
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
|
-
"sources": ["../src/crypto/secrets.ts", "../src/db/queries/tenants.ts"],
|
|
3
|
+
"sources": ["../src/mode.ts", "../src/crypto/secrets.ts", "../src/db/queries/tenants.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
|
-
"
|
|
5
|
+
"/**\n * Instance modes for the Secondlayer platform.\n *\n * - `oss`: self-hosted, single-tenant. No auth middleware, no platform routes\n * (projects, admin, workflows). Everything runs against a single\n * `DATABASE_URL`. Intended for `docker compose up`.\n *\n * - `dedicated`: per-customer managed instance. JWT-based auth (anon =\n * read-only, service = full). Dual-DB mode — shared source indexer DB for\n * block reads, per-tenant target DB for subgraph data. No platform-wide\n * routes mounted (no cross-tenant accounts).\n *\n * - `platform`: control-plane mode. Magic-link auth, API keys, projects,\n * tenants, admin. Serves the dashboard + CLI against a single shared DB.\n */\n\nexport type InstanceMode = \"oss\" | \"dedicated\" | \"platform\";\n\nconst VALID_MODES: readonly InstanceMode[] = [\"oss\", \"dedicated\", \"platform\"];\n\n/**\n * Resolve the active instance mode from `process.env.INSTANCE_MODE`.\n * Defaults to `\"oss\"` — the safest default for self-hosters who deploy\n * without setting the variable.\n */\nexport function getInstanceMode(): InstanceMode {\n\tconst raw = process.env.INSTANCE_MODE?.trim().toLowerCase();\n\tif (raw && (VALID_MODES as readonly string[]).includes(raw)) {\n\t\treturn raw as InstanceMode;\n\t}\n\treturn \"oss\";\n}\n\n/** True when the active mode is `\"platform\"` (shared multi-tenant). */\nexport function isPlatformMode(): boolean {\n\treturn getInstanceMode() === \"platform\";\n}\n\n/** True when the active mode is `\"oss\"` (self-hosted). */\nexport function isOssMode(): boolean {\n\treturn getInstanceMode() === \"oss\";\n}\n\n/** True when the active mode is `\"dedicated\"` (per-tenant managed). */\nexport function isDedicatedMode(): boolean {\n\treturn getInstanceMode() === \"dedicated\";\n}\n",
|
|
6
|
+
"import { createCipheriv, createDecipheriv, randomBytes } from \"node:crypto\";\nimport { appendFileSync, existsSync, readFileSync } from \"node:fs\";\nimport { resolve } from \"node:path\";\nimport { getInstanceMode } from \"../mode.ts\";\n\n/**\n * AES-256-GCM symmetric envelope for encrypted secrets at rest (tenant keys,\n * subscription signing secrets, etc.).\n *\n * Ciphertext layout: `iv (12 bytes) || authTag (16 bytes) || ciphertext`\n *\n * The key comes from `SECONDLAYER_SECRETS_KEY` — 32 bytes hex. In OSS mode,\n * if the env var is unset on first use we autogenerate a key and persist it\n * to `.env.local` in the current working directory so subsequent restarts\n * pick it up without user intervention. Dedicated/platform modes throw —\n * those runtimes must provision the key explicitly.\n *\n * Rotation strategy: re-encrypt all rows with the new key and swap the env\n * var. Not zero-downtime, but acceptable at v2 scale. For real KMS (AWS\n * KMS, Vault, GCP KMS), wrap the same byte layout behind an\n * `EncryptSecret`/`DecryptSecret` interface and swap at startup.\n */\n\nconst KEY_ENV = \"SECONDLAYER_SECRETS_KEY\";\nconst IV_LEN = 12;\nconst TAG_LEN = 16;\n\nfunction bootstrapOssKey(): string {\n\tconst envPath = resolve(process.cwd(), \".env.local\");\n\n\t// Check existing .env.local first — prior run may have written it.\n\tif (existsSync(envPath)) {\n\t\tconst contents = readFileSync(envPath, \"utf8\");\n\t\tconst match = contents.match(/^SECONDLAYER_SECRETS_KEY=([a-fA-F0-9]{64})/m);\n\t\tif (match) {\n\t\t\tprocess.env[KEY_ENV] = match[1];\n\t\t\treturn match[1];\n\t\t}\n\t}\n\n\tconst hex = randomBytes(32).toString(\"hex\");\n\tconst line = `${existsSync(envPath) ? \"\\n\" : \"\"}${KEY_ENV}=${hex}\\n`;\n\tappendFileSync(envPath, line, { mode: 0o600 });\n\tprocess.env[KEY_ENV] = hex;\n\tconsole.log(\n\t\t`[secondlayer] generated ${KEY_ENV}; saved to ${envPath} (mode 0600)`,\n\t);\n\treturn hex;\n}\n\nfunction loadKey(): Buffer {\n\tlet hex = process.env[KEY_ENV];\n\tif (!hex) {\n\t\tif (getInstanceMode() === \"oss\") {\n\t\t\thex = bootstrapOssKey();\n\t\t} else {\n\t\t\tthrow new Error(\n\t\t\t\t`${KEY_ENV} not set. Generate one with: openssl rand -hex 32`,\n\t\t\t);\n\t\t}\n\t}\n\tconst key = Buffer.from(hex, \"hex\");\n\tif (key.length !== 32) {\n\t\tthrow new Error(`${KEY_ENV} must be 32 bytes hex (got ${key.length})`);\n\t}\n\treturn key;\n}\n\nlet _cachedKey: Buffer | null = null;\nfunction getKey(): Buffer {\n\tif (!_cachedKey) _cachedKey = loadKey();\n\treturn _cachedKey;\n}\n\nexport function encryptSecret(plaintext: string): Buffer {\n\tconst key = getKey();\n\tconst iv = randomBytes(IV_LEN);\n\tconst cipher = createCipheriv(\"aes-256-gcm\", key, iv);\n\tconst ciphertext = Buffer.concat([\n\t\tcipher.update(plaintext, \"utf8\"),\n\t\tcipher.final(),\n\t]);\n\tconst tag = cipher.getAuthTag();\n\treturn Buffer.concat([iv, tag, ciphertext]);\n}\n\nexport function decryptSecret(envelope: Buffer): string {\n\tconst key = getKey();\n\tconst iv = envelope.subarray(0, IV_LEN);\n\tconst tag = envelope.subarray(IV_LEN, IV_LEN + TAG_LEN);\n\tconst ciphertext = envelope.subarray(IV_LEN + TAG_LEN);\n\tconst decipher = createDecipheriv(\"aes-256-gcm\", key, iv);\n\tdecipher.setAuthTag(tag);\n\treturn decipher.update(ciphertext).toString(\"utf8\") + decipher.final(\"utf8\");\n}\n\n/** Generate a fresh 32-byte hex key suitable for `SECONDLAYER_SECRETS_KEY`. */\nexport function generateSecretsKey(): string {\n\treturn randomBytes(32).toString(\"hex\");\n}\n",
|
|
6
7
|
"import { type Kysely, sql } from \"kysely\";\nimport { decryptSecret, encryptSecret } from \"../../crypto/secrets.ts\";\nimport type { Database, InsertTenant, Tenant, TenantStatus } from \"../types.ts\";\n\n/**\n * Tenant registry queries. Encrypted columns are stored as `bytea` and\n * transparently encrypted/decrypted via `encryptSecret`/`decryptSecret`.\n *\n * Never return decrypted values from listTenants — only `getTenantCredentials`\n * surfaces plaintext, and only when explicitly called by a caller that\n * needs to hand creds to a CLI or dashboard session.\n */\n\nexport interface NewTenantInput {\n\taccountId: string;\n\tslug: string;\n\tplan: string;\n\tcpus: number;\n\tmemoryMb: number;\n\tstorageLimitMb: number;\n\tpgContainerId: string;\n\tapiContainerId: string;\n\tprocessorContainerId: string;\n\ttargetDatabaseUrl: string;\n\ttenantJwtSecret: string;\n\tanonKey: string;\n\tserviceKey: string;\n\tapiUrlInternal: string;\n\tapiUrlPublic: string;\n\tprojectId?: string;\n}\n\nexport async function insertTenant(\n\tdb: Kysely<Database>,\n\tinput: NewTenantInput,\n): Promise<Tenant> {\n\tconst row: InsertTenant = {\n\t\taccount_id: input.accountId,\n\t\tslug: input.slug,\n\t\tstatus: \"active\",\n\t\tplan: input.plan,\n\t\tcpus: input.cpus,\n\t\tmemory_mb: input.memoryMb,\n\t\tstorage_limit_mb: input.storageLimitMb,\n\t\tpg_container_id: input.pgContainerId,\n\t\tapi_container_id: input.apiContainerId,\n\t\tprocessor_container_id: input.processorContainerId,\n\t\ttarget_database_url_enc: encryptSecret(input.targetDatabaseUrl),\n\t\ttenant_jwt_secret_enc: encryptSecret(input.tenantJwtSecret),\n\t\tanon_key_enc: encryptSecret(input.anonKey),\n\t\tservice_key_enc: encryptSecret(input.serviceKey),\n\t\tapi_url_internal: input.apiUrlInternal,\n\t\tapi_url_public: input.apiUrlPublic,\n\t\tproject_id: input.projectId ?? null,\n\t};\n\treturn db\n\t\t.insertInto(\"tenants\")\n\t\t.values(row)\n\t\t.returningAll()\n\t\t.executeTakeFirstOrThrow();\n}\n\nexport async function getTenantByAccount(\n\tdb: Kysely<Database>,\n\taccountId: string,\n): Promise<Tenant | null> {\n\tconst row = await db\n\t\t.selectFrom(\"tenants\")\n\t\t.selectAll()\n\t\t.where(\"account_id\", \"=\", accountId)\n\t\t.where(\"status\", \"<>\", \"deleted\")\n\t\t.orderBy(\"created_at\", \"desc\")\n\t\t.executeTakeFirst();\n\treturn row ?? null;\n}\n\nexport async function getTenantBySlug(\n\tdb: Kysely<Database>,\n\tslug: string,\n): Promise<Tenant | null> {\n\tconst row = await db\n\t\t.selectFrom(\"tenants\")\n\t\t.selectAll()\n\t\t.where(\"slug\", \"=\", slug)\n\t\t.executeTakeFirst();\n\treturn row ?? null;\n}\n\nexport async function listTenantsByStatus(\n\tdb: Kysely<Database>,\n\tstatus: TenantStatus,\n): Promise<Tenant[]> {\n\treturn db\n\t\t.selectFrom(\"tenants\")\n\t\t.selectAll()\n\t\t.where(\"status\", \"=\", status)\n\t\t.execute();\n}\n\n/**\n * Tenants considered \"idle\" for auto-pause on the Hobby tier. Active = any\n * successful tenant-API query OR workflow run wrote `last_active_at` within\n * the threshold.\n */\nexport async function listIdleHobbyTenants(\n\tdb: Kysely<Database>,\n\tidleSince: Date,\n): Promise<Tenant[]> {\n\treturn db\n\t\t.selectFrom(\"tenants\")\n\t\t.selectAll()\n\t\t.where(\"status\", \"=\", \"active\")\n\t\t.where(\"plan\", \"=\", \"hobby\")\n\t\t.where(\"last_active_at\", \"<\", idleSince)\n\t\t.execute();\n}\n\n/**\n * Bump `last_active_at` for a tenant. Callers are expected to throttle\n * (don't hammer on every request) — the activity middleware + workflow-\n * runner enforce a 60s per-tenant min between writes.\n */\nexport async function bumpTenantActivity(\n\tdb: Kysely<Database>,\n\tslug: string,\n): Promise<void> {\n\tawait db\n\t\t.updateTable(\"tenants\")\n\t\t.set({ last_active_at: new Date() })\n\t\t.where(\"slug\", \"=\", slug)\n\t\t.execute();\n}\n\nexport async function listSuspendedOlderThan(\n\tdb: Kysely<Database>,\n\tolderThan: Date,\n): Promise<Tenant[]> {\n\treturn db\n\t\t.selectFrom(\"tenants\")\n\t\t.selectAll()\n\t\t.where(\"status\", \"=\", \"suspended\")\n\t\t.where(\"suspended_at\", \"<\", olderThan)\n\t\t.execute();\n}\n\nexport async function setTenantStatus(\n\tdb: Kysely<Database>,\n\tslug: string,\n\tstatus: TenantStatus,\n): Promise<void> {\n\tconst patch: Record<string, unknown> = {\n\t\tstatus,\n\t\tupdated_at: new Date(),\n\t};\n\tif (status === \"suspended\") patch.suspended_at = new Date();\n\tif (status === \"active\") patch.suspended_at = null;\n\tawait db.updateTable(\"tenants\").set(patch).where(\"slug\", \"=\", slug).execute();\n}\n\nexport async function recordHealthCheck(\n\tdb: Kysely<Database>,\n\tslug: string,\n\tstorageUsedMb: number | null,\n): Promise<void> {\n\tawait db\n\t\t.updateTable(\"tenants\")\n\t\t.set({\n\t\t\tlast_health_check_at: new Date(),\n\t\t\tstorage_used_mb: storageUsedMb,\n\t\t\tupdated_at: new Date(),\n\t\t})\n\t\t.where(\"slug\", \"=\", slug)\n\t\t.execute();\n}\n\n/**\n * Record a storage measurement into the current calendar month's bucket.\n * Maintains peak, running average, and the most recent value in a single\n * upsert. Billing will consume this later; for now the table just gives\n * us evidence of usage over time.\n */\nexport async function recordMonthlyUsage(\n\tdb: Kysely<Database>,\n\ttenantId: string,\n\tstorageMb: number,\n): Promise<void> {\n\t// Bucket is the first day of the current month (UTC), so the unique\n\t// (tenant_id, period_month) constraint groups all samples cleanly.\n\tconst now = new Date();\n\tconst periodMonth = new Date(\n\t\tDate.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1),\n\t);\n\n\t// Running mean: avg_new = (avg_old * n + x) / (n + 1). Doing it in SQL\n\t// keeps the write atomic — no read-modify-write race between ticks.\n\tawait sql`\n\t\tINSERT INTO tenant_usage_monthly (\n\t\t\ttenant_id, period_month,\n\t\t\tstorage_peak_mb, storage_avg_mb, storage_last_mb,\n\t\t\tmeasurements, first_at, last_at\n\t\t) VALUES (\n\t\t\t${tenantId}, ${periodMonth},\n\t\t\t${storageMb}, ${storageMb}, ${storageMb},\n\t\t\t1, now(), now()\n\t\t)\n\t\tON CONFLICT (tenant_id, period_month) DO UPDATE SET\n\t\t\tstorage_peak_mb = GREATEST(tenant_usage_monthly.storage_peak_mb, EXCLUDED.storage_last_mb),\n\t\t\tstorage_avg_mb = (\n\t\t\t\t(tenant_usage_monthly.storage_avg_mb * tenant_usage_monthly.measurements + EXCLUDED.storage_last_mb)\n\t\t\t\t/ (tenant_usage_monthly.measurements + 1)\n\t\t\t),\n\t\t\tstorage_last_mb = EXCLUDED.storage_last_mb,\n\t\t\tmeasurements = tenant_usage_monthly.measurements + 1,\n\t\t\tlast_at = now()\n\t`.execute(db);\n}\n\nexport async function updateTenantPlan(\n\tdb: Kysely<Database>,\n\tslug: string,\n\tplan: string,\n\tcpus: number,\n\tmemoryMb: number,\n\tstorageLimitMb: number,\n): Promise<void> {\n\tawait db\n\t\t.updateTable(\"tenants\")\n\t\t.set({\n\t\t\tplan,\n\t\t\tcpus,\n\t\t\tmemory_mb: memoryMb,\n\t\t\tstorage_limit_mb: storageLimitMb,\n\t\t\tupdated_at: new Date(),\n\t\t})\n\t\t.where(\"slug\", \"=\", slug)\n\t\t.execute();\n}\n\nexport type RotateType = \"service\" | \"anon\" | \"both\";\n\n/**\n * Bump the selected gen counter(s) by 1 and return the new values.\n * Used by the key-rotate endpoint to force the tenant API to reject\n * previously-issued tokens of the rotated role(s).\n */\nexport async function bumpTenantKeyGen(\n\tdb: Kysely<Database>,\n\tslug: string,\n\ttype: RotateType,\n): Promise<{ serviceGen: number; anonGen: number }> {\n\tconst bumpService = type === \"service\" || type === \"both\";\n\tconst bumpAnon = type === \"anon\" || type === \"both\";\n\tconst row = await db\n\t\t.updateTable(\"tenants\")\n\t\t.set((eb) => ({\n\t\t\tservice_gen: bumpService\n\t\t\t\t? eb(\"service_gen\", \"+\", 1)\n\t\t\t\t: eb.ref(\"service_gen\"),\n\t\t\tanon_gen: bumpAnon ? eb(\"anon_gen\", \"+\", 1) : eb.ref(\"anon_gen\"),\n\t\t\tupdated_at: new Date(),\n\t\t}))\n\t\t.where(\"slug\", \"=\", slug)\n\t\t.returning([\"service_gen\", \"anon_gen\"])\n\t\t.executeTakeFirstOrThrow();\n\treturn { serviceGen: row.service_gen, anonGen: row.anon_gen };\n}\n\n/**\n * Replace the encrypted key columns after a successful rotate. Only the\n * rotated column(s) are written — the other stays untouched.\n */\nexport async function updateTenantKeys(\n\tdb: Kysely<Database>,\n\tslug: string,\n\tkeys: { serviceKey?: string; anonKey?: string },\n): Promise<void> {\n\tconst patch: Record<string, unknown> = { updated_at: new Date() };\n\tif (keys.serviceKey) patch.service_key_enc = encryptSecret(keys.serviceKey);\n\tif (keys.anonKey) patch.anon_key_enc = encryptSecret(keys.anonKey);\n\tif (Object.keys(patch).length === 1) return; // only updated_at — nothing to write\n\tawait db.updateTable(\"tenants\").set(patch).where(\"slug\", \"=\", slug).execute();\n}\n\n/**\n * Hard-delete a tenant row. Call only AFTER the provisioner has torn down\n * containers + volume; otherwise orphaned resources linger. Returns whether\n * a row was actually deleted.\n */\nexport async function deleteTenant(\n\tdb: Kysely<Database>,\n\tslug: string,\n): Promise<boolean> {\n\tconst res = await db\n\t\t.deleteFrom(\"tenants\")\n\t\t.where(\"slug\", \"=\", slug)\n\t\t.executeTakeFirst();\n\treturn (res.numDeletedRows ?? 0n) > 0n;\n}\n\nexport interface TenantCredentials {\n\tslug: string;\n\ttargetDatabaseUrl: string;\n\ttenantJwtSecret: string;\n\tanonKey: string;\n\tserviceKey: string;\n\tapiUrlInternal: string;\n\tapiUrlPublic: string;\n}\n\n/**\n * Decrypts the four encrypted columns and returns them plaintext. Call\n * this only when surfacing credentials to an authorized caller (dashboard,\n * CLI). Never log the returned object.\n */\nexport async function getTenantCredentials(\n\tdb: Kysely<Database>,\n\tslug: string,\n): Promise<TenantCredentials | null> {\n\tconst row = await db\n\t\t.selectFrom(\"tenants\")\n\t\t.select([\n\t\t\t\"slug\",\n\t\t\t\"target_database_url_enc\",\n\t\t\t\"tenant_jwt_secret_enc\",\n\t\t\t\"anon_key_enc\",\n\t\t\t\"service_key_enc\",\n\t\t\t\"api_url_internal\",\n\t\t\t\"api_url_public\",\n\t\t])\n\t\t.where(\"slug\", \"=\", slug)\n\t\t.executeTakeFirst();\n\tif (!row) return null;\n\treturn {\n\t\tslug: row.slug,\n\t\ttargetDatabaseUrl: decryptSecret(row.target_database_url_enc),\n\t\ttenantJwtSecret: decryptSecret(row.tenant_jwt_secret_enc),\n\t\tanonKey: decryptSecret(row.anon_key_enc),\n\t\tserviceKey: decryptSecret(row.service_key_enc),\n\t\tapiUrlInternal: row.api_url_internal,\n\t\tapiUrlPublic: row.api_url_public,\n\t};\n}\n"
|
|
7
8
|
],
|
|
8
|
-
"mappings": ";;;;;;;;;;;;;;;;;AAAA;
|
|
9
|
-
"debugId": "
|
|
9
|
+
"mappings": "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",
|
|
10
|
+
"debugId": "9AAE380FD6880D8264756E2164756E21",
|
|
10
11
|
"names": []
|
|
11
12
|
}
|