@prodverdict/engine 0.0.1
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/config/index.d.ts +3 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +3 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/parse.d.ts +4 -0
- package/dist/config/parse.d.ts.map +1 -0
- package/dist/config/parse.js +40 -0
- package/dist/config/parse.js.map +1 -0
- package/dist/config/schema.d.ts +468 -0
- package/dist/config/schema.d.ts.map +1 -0
- package/dist/config/schema.js +66 -0
- package/dist/config/schema.js.map +1 -0
- package/dist/connectors/fixture.d.ts +5 -0
- package/dist/connectors/fixture.d.ts.map +1 -0
- package/dist/connectors/fixture.js +16 -0
- package/dist/connectors/fixture.js.map +1 -0
- package/dist/connectors/index.d.ts +8 -0
- package/dist/connectors/index.d.ts.map +1 -0
- package/dist/connectors/index.js +6 -0
- package/dist/connectors/index.js.map +1 -0
- package/dist/connectors/load-fixtures.d.ts +9 -0
- package/dist/connectors/load-fixtures.d.ts.map +1 -0
- package/dist/connectors/load-fixtures.js +27 -0
- package/dist/connectors/load-fixtures.js.map +1 -0
- package/dist/connectors/postgres-live.d.ts +4 -0
- package/dist/connectors/postgres-live.d.ts.map +1 -0
- package/dist/connectors/postgres-live.js +51 -0
- package/dist/connectors/postgres-live.js.map +1 -0
- package/dist/connectors/sql-identifiers.d.ts +3 -0
- package/dist/connectors/sql-identifiers.d.ts.map +1 -0
- package/dist/connectors/sql-identifiers.js +13 -0
- package/dist/connectors/sql-identifiers.js.map +1 -0
- package/dist/connectors/stripe-live.d.ts +3 -0
- package/dist/connectors/stripe-live.d.ts.map +1 -0
- package/dist/connectors/stripe-live.js +32 -0
- package/dist/connectors/stripe-live.js.map +1 -0
- package/dist/connectors/types.d.ts +23 -0
- package/dist/connectors/types.d.ts.map +1 -0
- package/dist/connectors/types.js +2 -0
- package/dist/connectors/types.js.map +1 -0
- package/dist/evaluators/access.d.ts +9 -0
- package/dist/evaluators/access.d.ts.map +1 -0
- package/dist/evaluators/access.js +132 -0
- package/dist/evaluators/access.js.map +1 -0
- package/dist/evaluators/config.d.ts +22 -0
- package/dist/evaluators/config.d.ts.map +1 -0
- package/dist/evaluators/config.js +191 -0
- package/dist/evaluators/config.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +22 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/dist/types.js.map +1 -0
- package/dist/verdict.d.ts +3 -0
- package/dist/verdict.d.ts.map +1 -0
- package/dist/verdict.js +8 -0
- package/dist/verdict.js.map +1 -0
- package/package.json +39 -0
- package/src/config/index.ts +2 -0
- package/src/config/parse.test.ts +63 -0
- package/src/config/parse.ts +48 -0
- package/src/config/schema.ts +83 -0
- package/src/connectors/fixture.ts +18 -0
- package/src/connectors/index.ts +7 -0
- package/src/connectors/load-fixtures.test.ts +19 -0
- package/src/connectors/load-fixtures.ts +45 -0
- package/src/connectors/postgres-live.ts +61 -0
- package/src/connectors/sql-identifiers.test.ts +14 -0
- package/src/connectors/sql-identifiers.ts +16 -0
- package/src/connectors/stripe-live.ts +38 -0
- package/src/connectors/types.ts +25 -0
- package/src/evaluators/access.test.ts +206 -0
- package/src/evaluators/access.ts +159 -0
- package/src/evaluators/config.test.ts +266 -0
- package/src/evaluators/config.ts +213 -0
- package/src/index.ts +12 -0
- package/src/types.ts +27 -0
- package/src/verdict.test.ts +29 -0
- package/src/verdict.ts +7 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type { Finding, CheckResult, Verdict, Severity, ContractType, ProdVerdictError } from './types.js';
|
|
2
|
+
export { isProdVerdictError } from './types.js';
|
|
3
|
+
export { parseConfigFile, validateConfig } from './config/index.js';
|
|
4
|
+
export type { ProdVerdictConfig, AccessContractConfig, ConfigContractConfig, ConfigRule } from './config/index.js';
|
|
5
|
+
export type { StripeReader, StripeSubscription, DatabaseReader, AppUser } from './connectors/index.js';
|
|
6
|
+
export { createLiveStripeReader, createLivePostgresReader, createFixtureStripeReader, createFixtureDatabaseReader, loadFixtureSubscriptions, loadFixtureUsers, defaultFixturePaths, assertSqlIdentifier, assertSqlIdentifiers } from './connectors/index.js';
|
|
7
|
+
export type { FixturePaths } from './connectors/index.js';
|
|
8
|
+
export { evaluateAccess } from './evaluators/access.js';
|
|
9
|
+
export type { AccessDataSources } from './evaluators/access.js';
|
|
10
|
+
export { evaluateConfig, scanEnvReferences, parseEnvFile } from './evaluators/config.js';
|
|
11
|
+
export type { ConfigDataSources } from './evaluators/config.js';
|
|
12
|
+
export { aggregateVerdict } from './verdict.js';
|
|
13
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAC1G,OAAO,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAChD,OAAO,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACpE,YAAY,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,oBAAoB,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACnH,YAAY,EAAE,YAAY,EAAE,kBAAkB,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,uBAAuB,CAAC;AACvG,OAAO,EAAE,sBAAsB,EAAE,wBAAwB,EAAE,yBAAyB,EAAE,2BAA2B,EAAE,wBAAwB,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAC;AAC7P,YAAY,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAC1D,OAAO,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AACxD,YAAY,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAChE,OAAO,EAAE,cAAc,EAAE,iBAAiB,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AACzF,YAAY,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAChE,OAAO,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { isProdVerdictError } from './types.js';
|
|
2
|
+
export { parseConfigFile, validateConfig } from './config/index.js';
|
|
3
|
+
export { createLiveStripeReader, createLivePostgresReader, createFixtureStripeReader, createFixtureDatabaseReader, loadFixtureSubscriptions, loadFixtureUsers, defaultFixturePaths, assertSqlIdentifier, assertSqlIdentifiers } from './connectors/index.js';
|
|
4
|
+
export { evaluateAccess } from './evaluators/access.js';
|
|
5
|
+
export { evaluateConfig, scanEnvReferences, parseEnvFile } from './evaluators/config.js';
|
|
6
|
+
export { aggregateVerdict } from './verdict.js';
|
|
7
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAChD,OAAO,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAGpE,OAAO,EAAE,sBAAsB,EAAE,wBAAwB,EAAE,yBAAyB,EAAE,2BAA2B,EAAE,wBAAwB,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAC;AAE7P,OAAO,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAExD,OAAO,EAAE,cAAc,EAAE,iBAAiB,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAEzF,OAAO,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type Severity = 'high' | 'medium' | 'low';
|
|
2
|
+
export type Verdict = 'pass' | 'warn' | 'fail';
|
|
3
|
+
export type ContractType = 'access' | 'config' | 'migration' | 'boundary' | 'restore';
|
|
4
|
+
export interface Finding {
|
|
5
|
+
contract: ContractType;
|
|
6
|
+
severity: Severity;
|
|
7
|
+
/** Namespaced entity identifier, e.g. "user:usr_abc" or "price:price_pro" */
|
|
8
|
+
entity: string;
|
|
9
|
+
message: string;
|
|
10
|
+
fix?: string | undefined;
|
|
11
|
+
}
|
|
12
|
+
export interface CheckResult {
|
|
13
|
+
contract: ContractType;
|
|
14
|
+
verdict: Verdict;
|
|
15
|
+
findings: Finding[];
|
|
16
|
+
evaluatedAt: string;
|
|
17
|
+
}
|
|
18
|
+
export interface ProdVerdictError extends Error {
|
|
19
|
+
code: 'CONFIG_INVALID' | 'CONNECTOR_ERROR' | 'UNKNOWN';
|
|
20
|
+
}
|
|
21
|
+
export declare function isProdVerdictError(err: unknown): err is ProdVerdictError;
|
|
22
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,QAAQ,GAAG,KAAK,CAAC;AACjD,MAAM,MAAM,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;AAC/C,MAAM,MAAM,YAAY,GAAG,QAAQ,GAAG,QAAQ,GAAG,WAAW,GAAG,UAAU,GAAG,SAAS,CAAC;AAEtF,MAAM,WAAW,OAAO;IACtB,QAAQ,EAAE,YAAY,CAAC;IACvB,QAAQ,EAAE,QAAQ,CAAC;IACnB,6EAA6E;IAC7E,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC1B;AAED,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,YAAY,CAAC;IACvB,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,OAAO,EAAE,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,gBAAiB,SAAQ,KAAK;IAC7C,IAAI,EAAE,gBAAgB,GAAG,iBAAiB,GAAG,SAAS,CAAC;CACxD;AAED,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,IAAI,gBAAgB,CAExE"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAwBA,MAAM,UAAU,kBAAkB,CAAC,GAAY;IAC7C,OAAO,GAAG,YAAY,KAAK,IAAI,MAAM,IAAI,GAAG,CAAC;AAC/C,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"verdict.d.ts","sourceRoot":"","sources":["../src/verdict.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AAEnD,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,OAAO,EAAE,GAAG,OAAO,CAI7D"}
|
package/dist/verdict.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"verdict.js","sourceRoot":"","sources":["../src/verdict.ts"],"names":[],"mappings":"AAEA,MAAM,UAAU,gBAAgB,CAAC,QAAmB;IAClD,IAAI,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,KAAK,MAAM,CAAC;QAAE,OAAO,MAAM,CAAC;IAC/D,IAAI,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,KAAK,QAAQ,IAAI,CAAC,CAAC,QAAQ,KAAK,KAAK,CAAC;QAAE,OAAO,MAAM,CAAC;IACzF,OAAO,MAAM,CAAC;AAChB,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@prodverdict/engine",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Core evaluation engine for ProdVerdict production contracts",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"src"
|
|
18
|
+
],
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "https://github.com/prodv-dev/prodverdict-sdk.git",
|
|
22
|
+
"directory": "packages/engine"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/prodv-dev/prodverdict-sdk#readme",
|
|
25
|
+
"keywords": ["prodverdict", "production-contracts", "stripe", "saas", "access-control"],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"prebuild": "node -e \"try{require('fs').unlinkSync('tsconfig.tsbuildinfo')}catch(e){}\"",
|
|
28
|
+
"build": "tsc -p tsconfig.json"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"stripe": "^16.0.0",
|
|
32
|
+
"pg": "^8.12.0",
|
|
33
|
+
"yaml": "^2.4.5",
|
|
34
|
+
"zod": "^3.23.8"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/pg": "^8.11.6"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { validateConfig } from './parse.js';
|
|
3
|
+
|
|
4
|
+
const validRaw = {
|
|
5
|
+
version: 1,
|
|
6
|
+
contracts: [
|
|
7
|
+
{
|
|
8
|
+
type: 'access',
|
|
9
|
+
source_of_truth: 'stripe',
|
|
10
|
+
database: { url_env: 'DATABASE_URL' },
|
|
11
|
+
stripe: { secret_env: 'STRIPE_SECRET_KEY' },
|
|
12
|
+
plans: { price_pro: 'pro', price_starter: 'starter' },
|
|
13
|
+
severity: 'high',
|
|
14
|
+
fix: 'Sync has_paid_access from webhooks.',
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe('validateConfig', () => {
|
|
20
|
+
it('accepts a valid config', () => {
|
|
21
|
+
const cfg = validateConfig(validRaw);
|
|
22
|
+
expect(cfg.version).toBe(1);
|
|
23
|
+
expect(cfg.contracts[0]?.type).toBe('access');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('applies default column names', () => {
|
|
27
|
+
const cfg = validateConfig(validRaw);
|
|
28
|
+
const db = cfg.contracts[0]!.database;
|
|
29
|
+
expect(db.columns.id).toBe('id');
|
|
30
|
+
expect(db.columns.has_paid_access).toBe('has_paid_access');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('applies default users_table', () => {
|
|
34
|
+
const cfg = validateConfig(validRaw);
|
|
35
|
+
expect(cfg.contracts[0]!.database.users_table).toBe('users');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('rejects wrong version', () => {
|
|
39
|
+
expect(() => validateConfig({ ...validRaw, version: 2 })).toThrow('prodverdict.yml is invalid');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('rejects missing contracts', () => {
|
|
43
|
+
expect(() => validateConfig({ version: 1 })).toThrow('prodverdict.yml is invalid');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('rejects empty contracts array', () => {
|
|
47
|
+
expect(() => validateConfig({ version: 1, contracts: [] })).toThrow('prodverdict.yml is invalid');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('rejects missing stripe.secret_env', () => {
|
|
51
|
+
const raw = structuredClone(validRaw);
|
|
52
|
+
// @ts-expect-error intentional invalid input
|
|
53
|
+
delete raw.contracts[0].stripe.secret_env;
|
|
54
|
+
expect(() => validateConfig(raw)).toThrow('prodverdict.yml is invalid');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('rejects missing database.url_env', () => {
|
|
58
|
+
const raw = structuredClone(validRaw);
|
|
59
|
+
// @ts-expect-error intentional invalid input
|
|
60
|
+
delete raw.contracts[0].database.url_env;
|
|
61
|
+
expect(() => validateConfig(raw)).toThrow('prodverdict.yml is invalid');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { parse as parseYaml } from 'yaml';
|
|
3
|
+
import { ZodError } from 'zod';
|
|
4
|
+
import { ProdVerdictConfigSchema, type ProdVerdictConfig } from './schema.js';
|
|
5
|
+
|
|
6
|
+
function makeConfigError(message: string): Error & { code: 'CONFIG_INVALID' } {
|
|
7
|
+
const err = new Error(message) as Error & { code: 'CONFIG_INVALID' };
|
|
8
|
+
err.code = 'CONFIG_INVALID';
|
|
9
|
+
return err;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function validateConfig(raw: unknown): ProdVerdictConfig {
|
|
13
|
+
const result = ProdVerdictConfigSchema.safeParse(raw);
|
|
14
|
+
if (!result.success) {
|
|
15
|
+
throw makeConfigError(formatZodError(result.error));
|
|
16
|
+
}
|
|
17
|
+
return result.data;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function parseConfigFile(filePath: string): ProdVerdictConfig {
|
|
21
|
+
let text: string;
|
|
22
|
+
try {
|
|
23
|
+
text = readFileSync(filePath, 'utf8');
|
|
24
|
+
} catch (err) {
|
|
25
|
+
throw makeConfigError(
|
|
26
|
+
`Cannot read config file at "${filePath}": ${String(err)}`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let raw: unknown;
|
|
31
|
+
try {
|
|
32
|
+
raw = parseYaml(text);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
throw makeConfigError(
|
|
35
|
+
`Failed to parse YAML in "${filePath}": ${String(err)}`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return validateConfig(raw);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function formatZodError(err: ZodError): string {
|
|
43
|
+
const lines = err.issues.map((issue) => {
|
|
44
|
+
const path = issue.path.join('.') || '(root)';
|
|
45
|
+
return ` ${path}: ${issue.message}`;
|
|
46
|
+
});
|
|
47
|
+
return `prodverdict.yml is invalid:\n${lines.join('\n')}`;
|
|
48
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
const SeveritySchema = z.enum(['high', 'medium', 'low']);
|
|
4
|
+
|
|
5
|
+
const AccessDatabaseSchema = z.object({
|
|
6
|
+
url_env: z.string().min(1).describe('Name of the env var holding DATABASE_URL'),
|
|
7
|
+
users_table: z.string().min(1).default('users'),
|
|
8
|
+
columns: z
|
|
9
|
+
.object({
|
|
10
|
+
id: z.string().default('id'),
|
|
11
|
+
stripe_customer_id: z.string().default('stripe_customer_id'),
|
|
12
|
+
has_paid_access: z.string().default('has_paid_access'),
|
|
13
|
+
plan: z.string().default('plan'),
|
|
14
|
+
})
|
|
15
|
+
.default({}),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const AccessStripeSchema = z.object({
|
|
19
|
+
secret_env: z.string().min(1).describe('Name of the env var holding STRIPE_SECRET_KEY'),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const AccessContractSchema = z.object({
|
|
23
|
+
type: z.literal('access'),
|
|
24
|
+
source_of_truth: z.literal('stripe').default('stripe'),
|
|
25
|
+
database: AccessDatabaseSchema,
|
|
26
|
+
stripe: AccessStripeSchema,
|
|
27
|
+
/** Map of Stripe price ID -> plan slug used in the app */
|
|
28
|
+
plans: z.record(z.string(), z.string()).optional(),
|
|
29
|
+
/** Default severity applied to findings if not overridden per-rule */
|
|
30
|
+
severity: SeveritySchema.default('high'),
|
|
31
|
+
/** Default human/agent-readable fix hint */
|
|
32
|
+
fix: z.string().optional(),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export type AccessContractConfig = z.infer<typeof AccessContractSchema>;
|
|
36
|
+
|
|
37
|
+
// ── Config Contract ────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
const ConfigRuleSchema = z.discriminatedUnion('type', [
|
|
40
|
+
z.object({
|
|
41
|
+
type: z.literal('required'),
|
|
42
|
+
name: z.string().min(1),
|
|
43
|
+
description: z.string().optional(),
|
|
44
|
+
severity: SeveritySchema.optional(),
|
|
45
|
+
}),
|
|
46
|
+
z.object({
|
|
47
|
+
type: z.literal('not_default'),
|
|
48
|
+
name: z.string().min(1),
|
|
49
|
+
forbidden_values: z.array(z.string()).min(1),
|
|
50
|
+
severity: SeveritySchema.optional(),
|
|
51
|
+
}),
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
export type ConfigRule = z.infer<typeof ConfigRuleSchema>;
|
|
55
|
+
|
|
56
|
+
const ConfigContractSchema = z.object({
|
|
57
|
+
type: z.literal('config'),
|
|
58
|
+
/** Default severity for findings */
|
|
59
|
+
severity: SeveritySchema.default('high'),
|
|
60
|
+
/** Explicit rules about individual env vars */
|
|
61
|
+
rules: z.array(ConfigRuleSchema).default([]),
|
|
62
|
+
/** Scan source code for process.env.* references and check against .env.example */
|
|
63
|
+
scan_references: z.boolean().default(true),
|
|
64
|
+
/** Path to the env example file (relative to repo root) */
|
|
65
|
+
env_example_file: z.string().default('.env.example'),
|
|
66
|
+
/** Warn if a required var's value looks like a placeholder */
|
|
67
|
+
check_placeholders: z.boolean().default(true),
|
|
68
|
+
/** Variable names to ignore during reference scan */
|
|
69
|
+
ignore_vars: z.array(z.string()).default([]),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
export type ConfigContractConfig = z.infer<typeof ConfigContractSchema>;
|
|
73
|
+
|
|
74
|
+
// ── Union ──────────────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
const ContractSchema = z.discriminatedUnion('type', [AccessContractSchema, ConfigContractSchema]);
|
|
77
|
+
|
|
78
|
+
export const ProdVerdictConfigSchema = z.object({
|
|
79
|
+
version: z.literal(1),
|
|
80
|
+
contracts: z.array(ContractSchema).min(1),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
export type ProdVerdictConfig = z.infer<typeof ProdVerdictConfigSchema>;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { StripeReader, StripeSubscription, DatabaseReader, AppUser } from './types.js';
|
|
2
|
+
|
|
3
|
+
/** In-memory connectors for testing — accepts pre-loaded data */
|
|
4
|
+
export function createFixtureStripeReader(subscriptions: StripeSubscription[]): StripeReader {
|
|
5
|
+
return {
|
|
6
|
+
async listSubscriptions() {
|
|
7
|
+
return subscriptions;
|
|
8
|
+
},
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createFixtureDatabaseReader(users: AppUser[]): DatabaseReader {
|
|
13
|
+
return {
|
|
14
|
+
async listUsers() {
|
|
15
|
+
return users;
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type { StripeReader, StripeSubscription, DatabaseReader, AppUser } from './types.js';
|
|
2
|
+
export { createLiveStripeReader } from './stripe-live.js';
|
|
3
|
+
export { createLivePostgresReader } from './postgres-live.js';
|
|
4
|
+
export { createFixtureStripeReader, createFixtureDatabaseReader } from './fixture.js';
|
|
5
|
+
export { loadFixtureSubscriptions, loadFixtureUsers, defaultFixturePaths } from './load-fixtures.js';
|
|
6
|
+
export type { FixturePaths } from './load-fixtures.js';
|
|
7
|
+
export { assertSqlIdentifier, assertSqlIdentifiers } from './sql-identifiers.js';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { join, dirname } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { loadFixtureSubscriptions, loadFixtureUsers, defaultFixturePaths } from './load-fixtures.js';
|
|
5
|
+
|
|
6
|
+
const fixturesDir = join(dirname(fileURLToPath(import.meta.url)), '../../../../fixtures');
|
|
7
|
+
|
|
8
|
+
describe('load-fixtures', () => {
|
|
9
|
+
it('loads stripe and db fixture JSON', () => {
|
|
10
|
+
const paths = defaultFixturePaths(fixturesDir);
|
|
11
|
+
const subs = loadFixtureSubscriptions(paths.stripeSubscriptions);
|
|
12
|
+
const users = loadFixtureUsers(paths.dbUsers);
|
|
13
|
+
|
|
14
|
+
expect(subs).toHaveLength(2);
|
|
15
|
+
expect(users).toHaveLength(2);
|
|
16
|
+
expect(subs[0]?.customerId).toBe('cus_fixture_active');
|
|
17
|
+
expect(users[0]?.hasPaidAccess).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import type { StripeSubscription, AppUser } from './types.js';
|
|
4
|
+
|
|
5
|
+
export interface FixturePaths {
|
|
6
|
+
stripeSubscriptions: string;
|
|
7
|
+
dbUsers: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function loadFixtureSubscriptions(filePath: string): StripeSubscription[] {
|
|
11
|
+
const raw = JSON.parse(readFileSync(filePath, 'utf8')) as Array<{
|
|
12
|
+
id: string;
|
|
13
|
+
customerId: string;
|
|
14
|
+
status: string;
|
|
15
|
+
priceIds: string[];
|
|
16
|
+
}>;
|
|
17
|
+
return raw.map((item) => ({
|
|
18
|
+
id: item.id,
|
|
19
|
+
customerId: item.customerId,
|
|
20
|
+
status: item.status,
|
|
21
|
+
priceIds: item.priceIds,
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function loadFixtureUsers(filePath: string): AppUser[] {
|
|
26
|
+
const raw = JSON.parse(readFileSync(filePath, 'utf8')) as Array<{
|
|
27
|
+
id: string;
|
|
28
|
+
stripe_customer_id: string | null;
|
|
29
|
+
has_paid_access: boolean;
|
|
30
|
+
plan: string | null;
|
|
31
|
+
}>;
|
|
32
|
+
return raw.map((row) => ({
|
|
33
|
+
id: row.id,
|
|
34
|
+
stripeCustomerId: row.stripe_customer_id,
|
|
35
|
+
hasPaidAccess: row.has_paid_access,
|
|
36
|
+
plan: row.plan,
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function defaultFixturePaths(fixturesDir: string): FixturePaths {
|
|
41
|
+
return {
|
|
42
|
+
stripeSubscriptions: join(fixturesDir, 'stripe', 'subscriptions.json'),
|
|
43
|
+
dbUsers: join(fixturesDir, 'db', 'users.json'),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import pg from 'pg';
|
|
2
|
+
import type { DatabaseReader, AppUser } from './types.js';
|
|
3
|
+
import type { AccessContractConfig } from '../config/schema.js';
|
|
4
|
+
import { assertSqlIdentifier, assertSqlIdentifiers } from './sql-identifiers.js';
|
|
5
|
+
|
|
6
|
+
function makeConnectorError(message: string): Error & { code: 'CONNECTOR_ERROR' } {
|
|
7
|
+
const err = new Error(message) as Error & { code: 'CONNECTOR_ERROR' };
|
|
8
|
+
err.code = 'CONNECTOR_ERROR';
|
|
9
|
+
return err;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createLivePostgresReader(cfg: AccessContractConfig): DatabaseReader {
|
|
13
|
+
const connectionString = process.env[cfg.database.url_env];
|
|
14
|
+
if (!connectionString) {
|
|
15
|
+
throw makeConnectorError(
|
|
16
|
+
`Missing required env var "${cfg.database.url_env}" for database connector. ` +
|
|
17
|
+
'Provide a read-only database connection string.',
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const cols = cfg.database.columns;
|
|
22
|
+
const table = cfg.database.users_table;
|
|
23
|
+
assertSqlIdentifier(table, 'database.users_table');
|
|
24
|
+
assertSqlIdentifiers(cols, 'database.columns');
|
|
25
|
+
|
|
26
|
+
const pool = new pg.Pool({ connectionString, max: 2 });
|
|
27
|
+
let closed = false;
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
async listUsers(): Promise<AppUser[]> {
|
|
31
|
+
if (closed) {
|
|
32
|
+
throw makeConnectorError('Database reader is closed.');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let client: pg.PoolClient | undefined;
|
|
36
|
+
try {
|
|
37
|
+
client = await pool.connect();
|
|
38
|
+
const res = await client.query(
|
|
39
|
+
`SELECT ${cols.id}, ${cols.stripe_customer_id}, ${cols.has_paid_access}, ${cols.plan} FROM ${table}`,
|
|
40
|
+
);
|
|
41
|
+
return res.rows.map((row): AppUser => ({
|
|
42
|
+
id: String(row[cols.id]),
|
|
43
|
+
stripeCustomerId: row[cols.stripe_customer_id] as string | null,
|
|
44
|
+
hasPaidAccess: Boolean(row[cols.has_paid_access]),
|
|
45
|
+
plan: row[cols.plan] as string | null,
|
|
46
|
+
}));
|
|
47
|
+
} catch (err) {
|
|
48
|
+
throw makeConnectorError(`Database query failed: ${String(err)}`);
|
|
49
|
+
} finally {
|
|
50
|
+
client?.release();
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
async close(): Promise<void> {
|
|
55
|
+
if (!closed) {
|
|
56
|
+
closed = true;
|
|
57
|
+
await pool.end();
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { assertSqlIdentifier } from './sql-identifiers.js';
|
|
3
|
+
|
|
4
|
+
describe('assertSqlIdentifier', () => {
|
|
5
|
+
it('accepts valid identifiers', () => {
|
|
6
|
+
expect(() => assertSqlIdentifier('users', 'table')).not.toThrow();
|
|
7
|
+
expect(() => assertSqlIdentifier('has_paid_access', 'column')).not.toThrow();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('rejects invalid identifiers', () => {
|
|
11
|
+
expect(() => assertSqlIdentifier('users; DROP TABLE', 'table')).toThrow(/Invalid SQL identifier/);
|
|
12
|
+
expect(() => assertSqlIdentifier('1users', 'table')).toThrow(/Invalid SQL identifier/);
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Matches safe Postgres identifiers for table/column names from config */
|
|
2
|
+
const SQL_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
3
|
+
|
|
4
|
+
export function assertSqlIdentifier(value: string, label: string): void {
|
|
5
|
+
if (!SQL_IDENTIFIER.test(value)) {
|
|
6
|
+
throw new Error(
|
|
7
|
+
`Invalid SQL identifier for ${label}: "${value}". Use only letters, numbers, and underscores.`,
|
|
8
|
+
);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function assertSqlIdentifiers(values: Record<string, string>, labelPrefix: string): void {
|
|
13
|
+
for (const [key, value] of Object.entries(values)) {
|
|
14
|
+
assertSqlIdentifier(value, `${labelPrefix}.${key}`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import Stripe from 'stripe';
|
|
2
|
+
import type { StripeReader, StripeSubscription } from './types.js';
|
|
3
|
+
|
|
4
|
+
function makeConnectorError(message: string): Error & { code: 'CONNECTOR_ERROR' } {
|
|
5
|
+
const err = new Error(message) as Error & { code: 'CONNECTOR_ERROR' };
|
|
6
|
+
err.code = 'CONNECTOR_ERROR';
|
|
7
|
+
return err;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function createLiveStripeReader(secretKeyEnvVar: string): StripeReader {
|
|
11
|
+
const key = process.env[secretKeyEnvVar];
|
|
12
|
+
if (!key) {
|
|
13
|
+
throw makeConnectorError(
|
|
14
|
+
`Missing required env var "${secretKeyEnvVar}" for Stripe connector. ` +
|
|
15
|
+
'Provide a restricted read-only Stripe secret key.',
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const client = new Stripe(key, { apiVersion: '2024-06-20' });
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
async listSubscriptions(): Promise<StripeSubscription[]> {
|
|
23
|
+
const results: StripeSubscription[] = [];
|
|
24
|
+
for await (const sub of client.subscriptions.list({ limit: 100 })) {
|
|
25
|
+
const priceIds = (sub.items?.data ?? [])
|
|
26
|
+
.map((item) => item.price?.id)
|
|
27
|
+
.filter((id): id is string => Boolean(id));
|
|
28
|
+
results.push({
|
|
29
|
+
id: sub.id,
|
|
30
|
+
customerId: typeof sub.customer === 'string' ? sub.customer : sub.customer.id,
|
|
31
|
+
status: sub.status,
|
|
32
|
+
priceIds,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return results;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Minimal Stripe subscription data needed for access evaluation */
|
|
2
|
+
export interface StripeSubscription {
|
|
3
|
+
id: string;
|
|
4
|
+
customerId: string;
|
|
5
|
+
status: string;
|
|
6
|
+
/** Active price IDs on the subscription (usually one) */
|
|
7
|
+
priceIds: string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface StripeReader {
|
|
11
|
+
listSubscriptions(): Promise<StripeSubscription[]>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Minimal app user row needed for access evaluation */
|
|
15
|
+
export interface AppUser {
|
|
16
|
+
id: string;
|
|
17
|
+
stripeCustomerId: string | null;
|
|
18
|
+
hasPaidAccess: boolean;
|
|
19
|
+
plan: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface DatabaseReader {
|
|
23
|
+
listUsers(): Promise<AppUser[]>;
|
|
24
|
+
close?(): Promise<void>;
|
|
25
|
+
}
|