Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ Then compile it into `rules.json` like so:
fireplan rules.yaml
```

You can also optionally generate TypeScript definitions (for NodeJS/Firebase consumers) from the
same schema:

```
fireplan rules.yaml --types-output rules.d.ts
```

## Syntax

Fireplan security rules are written in YAML, which gets translated to JSON by the compiler. Indentation indicates the hierarchical structure and there's no need for quotes, but otherwise it's
Expand Down Expand Up @@ -108,9 +115,10 @@ A function can take any number of arguments; if it doesn't take any, you can lea

Functions are called in the usual way, like `foo('bar', next.baz)`. A function can call other functions in its body but recursion is forbidden (and will crash the compiler). If a function doesn't take arguments you can also call it without parentheses, like `foo2`. This is especially convenient for defining new "value types", like `percentage` in the example at the top.


### Types

Fireplan predefines three value types `string`, `boolean` and `number` like so:
Fireplan predefines four value types `string`, `boolean`, `number`, and `any` like so:
```yaml
functions:
- string: next.isString()
Expand All @@ -119,10 +127,13 @@ functions:
- any: true # also implies .more: true for this child
```

There's also a special predefined function `oneOf` that is used to constrain a property to one of a list of values (typically strings). Use it like this (and prefix with `required` to taste):
There are also special predefined functions `oneOf` and `is` that are used to constrain a
property to a fixed set of values (typically strings). `oneOf` accepts multiple values, while
`is` accepts exactly one. Use them like this (and prefix with `required` to taste):
```yaml
root:
foo: oneOf('bar', 'baz', 'qux')
bar: is('quux')
```

Finally, for object types, you can apply YAML's referencing mechanism to reuse a definition in multiple places:
Expand Down Expand Up @@ -154,6 +165,25 @@ You can also suffix a `$` wildcard key with `/few` to indicate that you don't ex

If any `encrypted` or `few` annotations are present, Fireplan will emit a `rules_firecrypt.json` file that you can then feed into Firecrypt and related tools.

### TypeScript Definition Generation

When `--types-output` is provided, Fireplan emits a `.d.ts` file with a single top-level
`FirebaseData` type and nested anonymous object types mapped from Firebase paths. The generated
types cover:
- primitive value constraints (`string`, `number`, `boolean`, `any`),
- required vs optional children (`required` keyword),
- `oneOf(...)` and `is(...)` constraints as TypeScript literal unions,
- wildcard keys mapped to TypeScript index signatures (for example `[userKey: string]: {...}`),
- object branches with `.more: true` are open and accept `[key: string]: any`.

TypeScript limitation: there is no clean way to express "all string keys except these named
properties". For objects that mix named children with a wildcard child, Fireplan omits the named
children from generated types and emits only the wildcard index signature. If `.more: true` is
present on that same object, named children are kept and the wildcard index signature is widened to
`[key: string]: any`.

Rules that use expressions beyond these features are emitted as `unknown` in TypeScript.

## That's All!

Please let me know if you have any problems.
3 changes: 1 addition & 2 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,8 @@ export default [
languageOptions: {
globals: {
...globals.node,
...globals.es2018,
...globals.es2026,
},
ecmaVersion: 2018,
sourceType: 'commonjs'
}
},
Expand Down
7 changes: 6 additions & 1 deletion fireplan
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
const compiler = require('./index.js');
const argv = require('yargs')
.option('o', {alias: 'output', describe: 'output path', type: 'string'})
.option('t', {
alias: 'types-output',
describe: 'optional path for generated TypeScript definitions',
type: 'string'
})
.usage('$0 <input>', 'transform a fireplan rules file into a JSON rules file', yargs => {
yargs.positional('input', {
describe: 'the fireplan rules input file',
Expand All @@ -14,4 +19,4 @@ const argv = require('yargs')
.strict()
.argv;

compiler.transformFile(argv.input, argv.output);
compiler.transformFile(argv.input, argv.output, argv.typesOutput);
21 changes: 18 additions & 3 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const clone = require('clone');
const fs = require('fs');
const {dirname} = require('path');
const jsyaml = require('js-yaml');
const {generateTypes} = require('./type_generator');

const BUILTINS = {
auth: true, now: true, root: true, next: true, newData: true, prev: true, data: true, env: true,
Expand Down Expand Up @@ -253,7 +254,7 @@ class Compiler {
case 'prev': case 'data':
node.name = 'data'; node.output = 'snapshot'; break;
default: {
if (node.name === 'oneOf' || node.name === 'env') return;
if (node.name === 'oneOf' || node.name === 'is' || node.name === 'env') return;
if (_.includes(locals, node.name)) return;
const ref = refs && refs[node.name];
if (_.isNumber(ref)) {
Expand Down Expand Up @@ -334,7 +335,14 @@ class Compiler {
if (node.type === 'CallExpression' && node.callee.type === 'Identifier') {
if (_.includes(locals, node.callee.name)) return;
this.changed = true;
if (node.callee.name === 'oneOf') {
if (node.callee.name === 'oneOf' || node.callee.name === 'is') {
if (!node.arguments.length) {
throw new Error(`${node.callee.name}() expects at least one argument: ` +
this.generate(node));
}
if (node.callee.name === 'is' && node.arguments.length !== 1) {
throw new Error('is() expects exactly one argument: ' + this.generate(node));
}
let condition = {
type: 'BinaryExpression', operator: '==', left: NEW_DATA_VAL, right: node.arguments[0]
};
Expand Down Expand Up @@ -395,10 +403,13 @@ exports.transform = function(source) {
return new Compiler(source).transform();
};

exports.transformFile = function(input, output) {
exports.generateTypes = generateTypes;

exports.transformFile = function(input, output, typesOutput) {
if (!output) output = input.replace(/\.ya?ml$/, '') + '.json';
const rawSource = fs.readFileSync(input, 'utf8');
const source = jsyaml.load(rawSource, {filename: input, schema: jsyaml.DEFAULT_SAFE_SCHEMA});
const sourceForTypes = typesOutput ? clone(source) : null;
const rules = exports.transform(source);
// console.log(JSON.stringify(rules, null, 2));
fs.mkdirSync(dirname(output), {recursive: true});
Expand All @@ -407,4 +418,8 @@ exports.transformFile = function(input, output) {
const cryptOutput = output.replace(/\.json$/, '_firecrypt.json');
fs.writeFileSync(cryptOutput, JSON.stringify({rules: rules.firecrypt}, null, 2));
}
if (typesOutput) {
fs.mkdirSync(dirname(typesOutput), {recursive: true});
fs.writeFileSync(typesOutput, generateTypes(sourceForTypes));
}
};
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "fireplan",
"version": "3.1.5",
"version": "3.2",
"description": "Compiler for an alternative YAML-based syntax for Firebase security rules.",
"engines": {
"node": ">=14.0"
Expand Down
229 changes: 229 additions & 0 deletions type_generator.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
'use strict';

const _ = require('lodash');
const esprima = require('esprima');

class TypeGenerator {
constructor(source) {
this.source = source;
this.functionTypes = {
string: 'string',
number: 'number',
boolean: 'boolean',
any: 'any'
};
this.functionDefinitions = {};
this.loadFunctionDefinitions();
this.resolveFunctionTypes();
}

loadFunctionDefinitions() {
_.forEach(this.source.functions || [], definition => {
_.forEach(definition, (body, signature) => {
const match = signature.match(/^\s*(\w+)\s*(?:\((.*?)\))?\s*$/);
if (!match) return;
const name = match[1];
const args = _.compact(_.map((match[2] || '').split(','), _.trim));
this.functionDefinitions[name] = {body, args};
});
});
}

resolveFunctionTypes() {
let changed = true;
while (changed) {
changed = false;
const definitions = Object.entries(this.functionDefinitions);
for (const [name, definition] of definitions) {
if (definition.args.length) continue;
const inferredType = this.inferExpressionType(definition.body);
const shouldUpdate =
inferredType && inferredType !== 'unknown' && this.functionTypes[name] !== inferredType;
if (shouldUpdate) {
this.functionTypes[name] = inferredType;
changed = true;
}
}
}
}

inferExpressionType(expression) {
if (!_.isString(expression)) return 'unknown';
const parsed = this.parseConstraint(expression);
return this.disjunctionType(parsed.expression) ||
this.inferSingleExpressionType(parsed.expression) ||
'unknown';
}

disjunctionType(expression) {
const parts = this.disjunctionParts(expression);
if (parts.length <= 1) return;

const inferredTypes = _(parts)
.map(part => this.inferSingleExpressionType(part))
.filter(type => type && type !== 'unknown')
.flatMap(type => _.map(type.split('|'), _.trim))
.uniq()
.value();

if (!inferredTypes.length) return;
return inferredTypes.join(' | ');
}

inferSingleExpressionType(expression) {
return this.literalConstraintType(expression) ??
this.resolveFunctionReferenceType(expression);
}

disjunctionParts(expression) {
if (!_.isString(expression) || !_.includes(expression, '||')) return [expression];

let parsed;
try {
parsed = esprima.parseScript(expression, {range: true});
} catch (error) {
void error;
return _.map(expression.split('||'), _.trim);
}

if (!parsed.body.length || parsed.body[0].type !== 'ExpressionStatement') return [expression];

const parts = [];
const stack = [parsed.body[0].expression];
while (stack.length) {
const node = stack.pop();
if (node.type === 'LogicalExpression' && node.operator === '||') {
stack.push(node.right);
stack.push(node.left);
continue;
}
if (!node.range) return _.map(expression.split('||'), _.trim);
parts.push(_.trim(expression.slice(node.range[0], node.range[1])));
}
return parts;
}

generate() {
const root = this.toNode(this.source.root || {});
return [
'// Generated by fireplan. Do not edit directly.',
'',
`export type FirebaseData = ${this.typeString(root, 0)}`,
''
].join('\n');
}

typeString(node, indent) {
if (node.kind !== 'object') return node.type;
const wildcardEntry = _.find(node.entries, {wildcard: true});
const keepStaticEntries = !wildcardEntry || node.moreAllowed;
const rows = ['{'];
_.forEach(node.entries, entry => {
const type = this.typeString(entry.node, indent + 2);
const required = entry.required || entry.wildcard || type === 'any';

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep any-typed properties optional unless required

This forces every property inferred as any to be required in the generated type, even when the schema does not use the required keyword. In Fireplan rules, child presence is only enforced via required, so schemas like foo: any are valid when foo is absent, but the emitted type incorrectly requires foo and causes false type errors for consumers.

Useful? React with 👍 / 👎.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All required controls is whether the emitted property has a ? suffix, and AFAIK there's no point in having an any-typed property be optional.

if (entry.wildcard || keepStaticEntries) {
const key = entry.wildcard ? `[${entry.key}: string]` : entry.key;
const emittedType = entry.wildcard && node.moreAllowed ? 'any' : type;
rows.push(`${_.repeat(' ', indent + 2)}${key}${required ? '' : '?'}: ${emittedType}`);
}
});
if (node.moreAllowed && !wildcardEntry) {
rows.push(`${_.repeat(' ', indent + 2)}[key: string]: any`);
}
rows.push(`${_.repeat(' ', indent)}}`);
return rows.join('\n');
}

toNode(yaml) {
if (_.isString(yaml)) return this.fromConstraint(yaml);
if (!_.isObject(yaml) || _.isArray(yaml)) return {kind: 'leaf', type: 'unknown'};
const childKeys = _.filter(_.keys(yaml), key => key.charAt(0) !== '.');
if (!childKeys.length) {
if ('.value' in yaml) return this.fromConstraint(yaml['.value']);
if (yaml['.more']) return {kind: 'leaf', type: 'any'};
return {kind: 'leaf', type: 'unknown'};
}
return {
kind: 'object',
moreAllowed: yaml['.more'] === true,
entries: _.map(childKeys, key => {
const value = yaml[key];
const constraint = _.isString(value) ? value : value && value['.value'];
const wildcard = key.charAt(0) === '$';
return {
key: wildcard ? this.wildcardName(key.slice(1)) : this.propertyName(key),
wildcard,
required: wildcard || this.isRequired(constraint),
node: this.toNode(value)
};
})
};
}

wildcardName(key) {
key = key.replace(/\/.*/, '');
return key.match(/^[a-zA-Z_$][a-zA-Z0-9_$]*$/) ? key : 'key';
}

propertyName(key) {
key = key.replace(/\/.*/, '');
return key.match(/^[$a-zA-Z_][a-zA-Z0-9_$]*$/) ? key : JSON.stringify(key);
}

parseConstraint(constraint) {
if (!_.isString(constraint)) return {keywords: {}, expression: ''};
let rest = constraint;
const keywords = {};
while (true) {
const match = rest.match(/^\s*(required|indexed|encrypted(?:\[.*?\])?)(?:\s+|$)/);
if (!match) break;
const keyword = _.startsWith(match[1], 'encrypted') ? 'encrypted' : match[1];
keywords[keyword] = true;
rest = rest.slice(match[0].length);
}
return {keywords, expression: _.trim(rest)};
}

isRequired(constraint) {
return this.parseConstraint(constraint).keywords.required === true;
}

fromConstraint(constraint) {
if (!_.isString(constraint)) return {kind: 'leaf', type: 'unknown'};
const inferredType = this.inferExpressionType(constraint);
return {kind: 'leaf', type: inferredType || 'unknown'};
}

resolveFunctionReferenceType(expression) {
if (!_.isString(expression)) return;
const bareNameMatch = expression.match(/^(\w+)\b/);
if (!bareNameMatch) return;
const name = bareNameMatch[1];
const definition = this.functionDefinitions[name];
if (definition && definition.args.length) return;
return this.functionTypes[name];
}

literalConstraintType(expression) {
const match = expression.match(/^\s*(oneOf|is)\s*\(([\s\S]*?)\)/);
if (!match) return;
let parsed;
try {
parsed = esprima.parse(`${match[1]}(${match[2]})`).body[0].expression;
} catch (error) {
void error;
return;
}
if (!parsed || parsed.type !== 'CallExpression') return;
if (parsed.callee.name !== 'oneOf' && parsed.callee.name !== 'is') return;
if (parsed.callee.name === 'is' && parsed.arguments.length !== 1) return;
return _.map(parsed.arguments, arg => {
if (arg.type === 'Literal') return JSON.stringify(arg.value);
return 'unknown';
}).join(' | ');
}
}

exports.generateTypes = function(source) {
return new TypeGenerator(source).generate();
};
Loading