@tailor-platform/eslint-plugin-sdk 0.0.0

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/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # @tailor-platform/eslint-plugin-sdk
2
+
3
+ Lint rules for applications built with `@tailor-platform/sdk`. The plugin uses the ESLint v9
4
+ plugin API and can run with either ESLint or Oxlint.
5
+
6
+ ## Installation
7
+
8
+ Install the plugin with your linter:
9
+
10
+ ```bash
11
+ pnpm add --save-dev @tailor-platform/eslint-plugin-sdk oxlint
12
+ ```
13
+
14
+ ## Oxlint
15
+
16
+ Add the plugin and its rules to `.oxlintrc.json`:
17
+
18
+ ```json
19
+ {
20
+ "jsPlugins": [
21
+ {
22
+ "name": "tailor-sdk",
23
+ "specifier": "@tailor-platform/eslint-plugin-sdk"
24
+ }
25
+ ],
26
+ "rules": {
27
+ "tailor-sdk/no-api-prefix-in-path-pattern": "warn",
28
+ "tailor-sdk/no-unconditional-permit": "warn"
29
+ }
30
+ }
31
+ ```
32
+
33
+ Oxlint JavaScript plugins are currently alpha. These rules use syntax and import binding analysis
34
+ only; they do not require type-aware custom rule support.
35
+
36
+ ## ESLint
37
+
38
+ Use the recommended flat config with ESLint v9 or later:
39
+
40
+ ```js
41
+ import tailorSdk from "@tailor-platform/eslint-plugin-sdk";
42
+
43
+ export default [tailorSdk.configs.recommended];
44
+ ```
45
+
46
+ ## Rules
47
+
48
+ ### `no-api-prefix-in-path-pattern` (warning)
49
+
50
+ HTTP adapter path patterns are matched after the platform's `/api` prefix.
51
+
52
+ Incorrect:
53
+
54
+ ```ts
55
+ export default createHttpAdapter({
56
+ pathPattern: "/api/orders/*",
57
+ });
58
+ ```
59
+
60
+ Correct:
61
+
62
+ ```ts
63
+ export default createHttpAdapter({
64
+ pathPattern: "/orders/*",
65
+ });
66
+ ```
67
+
68
+ ### `no-unconditional-permit` (warning)
69
+
70
+ Permission entries with empty `conditions` and `permit: true` grant access to every request, as do
71
+ the `unsafeAllowAll*` constants. Use them only during local development.
72
+
73
+ Incorrect:
74
+
75
+ ```ts
76
+ export default db.type("User", fields).permission({
77
+ create: [{ conditions: [], permit: true }],
78
+ // ...
79
+ });
80
+
81
+ export const defaultPermission = unsafeAllowAllTypePermission;
82
+ ```
83
+
84
+ Correct:
85
+
86
+ ```ts
87
+ export default db.type("User", fields).permission({
88
+ create: [{ conditions: [[{ user: "role" }, "=", "ADMIN"]], permit: true }],
89
+ // ...
90
+ });
91
+ ```
92
+
93
+ The rule checks `.permission()` / `.gqlPermission()` on `db.type()` chains and the `permission`
94
+ option of `defineIdp()`, including values defined as `const` in the same file.
95
+
96
+ The rules recognize named and namespace imports from `@tailor-platform/sdk`, including local import
97
+ aliases. Same-named functions imported from other packages are ignored.
package/index.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ import type { ESLint, Linter, Rule } from "eslint";
2
+
3
+ export type TailorSdkRuleName = "no-api-prefix-in-path-pattern" | "no-unconditional-permit";
4
+
5
+ type RecommendedRules = {
6
+ readonly [RuleName in TailorSdkRuleName as `tailor-sdk/${RuleName}`]: Linter.RuleEntry;
7
+ };
8
+
9
+ export interface TailorSdkPlugin extends Omit<ESLint.Plugin, "configs" | "meta" | "rules"> {
10
+ readonly meta: {
11
+ readonly name: "@tailor-platform/eslint-plugin-sdk";
12
+ };
13
+ readonly rules: Record<TailorSdkRuleName, Rule.RuleModule>;
14
+ readonly configs: {
15
+ readonly recommended: Linter.Config<RecommendedRules>;
16
+ };
17
+ }
18
+
19
+ declare const plugin: TailorSdkPlugin;
20
+
21
+ export default plugin;
package/index.js ADDED
@@ -0,0 +1,28 @@
1
+ import noApiPrefixInPathPattern from "./rules/no-api-prefix-in-path-pattern.js";
2
+ import noUnconditionalPermit from "./rules/no-unconditional-permit.js";
3
+
4
+ const rules = {
5
+ "no-api-prefix-in-path-pattern": noApiPrefixInPathPattern,
6
+ "no-unconditional-permit": noUnconditionalPermit,
7
+ };
8
+
9
+ const plugin = {
10
+ meta: {
11
+ name: "@tailor-platform/eslint-plugin-sdk",
12
+ },
13
+ rules,
14
+ configs: {},
15
+ };
16
+
17
+ plugin.configs.recommended = {
18
+ name: "tailor-sdk/recommended",
19
+ plugins: {
20
+ "tailor-sdk": plugin,
21
+ },
22
+ rules: {
23
+ "tailor-sdk/no-api-prefix-in-path-pattern": "warn",
24
+ "tailor-sdk/no-unconditional-permit": "warn",
25
+ },
26
+ };
27
+
28
+ export default plugin;
package/lib/ast.js ADDED
@@ -0,0 +1,64 @@
1
+ const EXPRESSION_WRAPPERS = new Set([
2
+ "ChainExpression",
3
+ "TSAsExpression",
4
+ "TSInstantiationExpression",
5
+ "TSNonNullExpression",
6
+ "TSSatisfiesExpression",
7
+ "TSTypeAssertion",
8
+ "TypeCastExpression",
9
+ ]);
10
+
11
+ export function unwrapExpression(node) {
12
+ let current = node;
13
+ while (current !== null && current !== undefined && EXPRESSION_WRAPPERS.has(current.type)) {
14
+ current = current.expression;
15
+ }
16
+ return current;
17
+ }
18
+
19
+ export function memberName(node) {
20
+ if (!node || (node.type !== "MemberExpression" && node.type !== "OptionalMemberExpression")) {
21
+ return null;
22
+ }
23
+ if (!node.computed && node.property.type === "Identifier") {
24
+ return node.property.name;
25
+ }
26
+ if (
27
+ node.computed &&
28
+ node.property.type === "Literal" &&
29
+ typeof node.property.value === "string"
30
+ ) {
31
+ return node.property.value;
32
+ }
33
+ return null;
34
+ }
35
+
36
+ export function nodeStart(node) {
37
+ if (Array.isArray(node.range)) return node.range[0];
38
+ return typeof node.start === "number" ? node.start : 0;
39
+ }
40
+
41
+ export function staticString(node) {
42
+ const value = unwrapExpression(node);
43
+ if (value?.type === "Literal" && typeof value.value === "string") {
44
+ return value.value;
45
+ }
46
+ if (value?.type === "TemplateLiteral" && value.expressions.length === 0) {
47
+ return value.quasis[0]?.value.cooked ?? value.quasis[0]?.value.raw ?? null;
48
+ }
49
+ return null;
50
+ }
51
+
52
+ export function objectProperty(object, name) {
53
+ const value = unwrapExpression(object);
54
+ if (value?.type !== "ObjectExpression") return null;
55
+ return (
56
+ value.properties.findLast((property) => {
57
+ if (property.type !== "Property" || property.kind !== "init") return false;
58
+ if (!property.computed && property.key.type === "Identifier") {
59
+ return property.key.name === name;
60
+ }
61
+ return property.key.type === "Literal" && property.key.value === name;
62
+ }) ?? null
63
+ );
64
+ }
@@ -0,0 +1,87 @@
1
+ import { memberName, nodeStart, unwrapExpression } from "./ast.js";
2
+
3
+ export const SDK_CONFIGURE_MODULE = "@tailor-platform/sdk";
4
+
5
+ function findVariable(sourceCode, node) {
6
+ let scope = sourceCode.getScope(node);
7
+ while (scope !== null) {
8
+ const variable = scope.set.get(node.name);
9
+ if (variable) return variable;
10
+ scope = scope.upper;
11
+ }
12
+ return null;
13
+ }
14
+
15
+ function isBindingReference(context, node, binding) {
16
+ if (node?.type !== "Identifier") return false;
17
+ const variable = findVariable(context.sourceCode, node);
18
+ return (
19
+ variable?.identifiers.some((identifier) => nodeStart(identifier) === nodeStart(binding)) ??
20
+ false
21
+ );
22
+ }
23
+
24
+ export function variableInitializer(context, node) {
25
+ if (node?.type !== "Identifier") return null;
26
+ const variable = findVariable(context.sourceCode, node);
27
+ const definition = variable?.defs.find(
28
+ (entry) =>
29
+ entry.type === "Variable" &&
30
+ entry.node?.type === "VariableDeclarator" &&
31
+ entry.node.parent?.kind === "const",
32
+ );
33
+ return definition?.node.init ?? null;
34
+ }
35
+
36
+ function createImportTracker(context, modules) {
37
+ const named = new Map();
38
+ const namespaces = new Map();
39
+
40
+ return {
41
+ track(node) {
42
+ if (!modules.has(node.source.value)) return;
43
+ for (const specifier of node.specifiers) {
44
+ if (specifier.type === "ImportNamespaceSpecifier") {
45
+ namespaces.set(specifier.local.name, specifier.local);
46
+ continue;
47
+ }
48
+ if (specifier.type !== "ImportSpecifier" || specifier.importKind === "type") continue;
49
+ const imported =
50
+ specifier.imported.type === "Identifier"
51
+ ? specifier.imported.name
52
+ : String(specifier.imported.value);
53
+ named.set(specifier.local.name, { binding: specifier.local, imported });
54
+ }
55
+ },
56
+
57
+ callName(call) {
58
+ const callee = unwrapExpression(call.callee);
59
+ if (callee?.type === "Identifier") {
60
+ const entry = named.get(callee.name);
61
+ return entry && isBindingReference(context, callee, entry.binding) ? entry.imported : null;
62
+ }
63
+ if (callee?.type !== "MemberExpression" && callee?.type !== "OptionalMemberExpression") {
64
+ return null;
65
+ }
66
+ const object = unwrapExpression(callee.object);
67
+ if (object?.type !== "Identifier" || !this.isNamespace(object)) return null;
68
+ return memberName(callee);
69
+ },
70
+
71
+ importedAs(node, importedName) {
72
+ if (node?.type !== "Identifier") return false;
73
+ const entry = named.get(node.name);
74
+ return entry?.imported === importedName && isBindingReference(context, node, entry.binding);
75
+ },
76
+
77
+ isNamespace(node) {
78
+ if (node?.type !== "Identifier") return false;
79
+ const binding = namespaces.get(node.name);
80
+ return binding !== undefined && isBindingReference(context, node, binding);
81
+ },
82
+ };
83
+ }
84
+
85
+ export function configureImportTracker(context) {
86
+ return createImportTracker(context, new Set([SDK_CONFIGURE_MODULE]));
87
+ }
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@tailor-platform/eslint-plugin-sdk",
3
+ "version": "0.0.0",
4
+ "description": "Lint rules for Tailor Platform SDK applications",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/tailor-platform/sdk.git",
9
+ "directory": "packages/eslint-plugin-sdk"
10
+ },
11
+ "files": [
12
+ "index.js",
13
+ "index.d.ts",
14
+ "lib",
15
+ "rules",
16
+ "README.md"
17
+ ],
18
+ "type": "module",
19
+ "main": "./index.js",
20
+ "types": "./index.d.ts",
21
+ "exports": {
22
+ ".": {
23
+ "types": "./index.d.ts",
24
+ "import": "./index.js",
25
+ "default": "./index.js"
26
+ }
27
+ },
28
+ "scripts": {
29
+ "lint": "oxlint .",
30
+ "lint:fix": "oxlint . --fix",
31
+ "typecheck": "tsc --noEmit",
32
+ "test": "vitest run",
33
+ "publint": "publint --strict"
34
+ },
35
+ "dependencies": {
36
+ "@types/eslint": "9.6.1"
37
+ },
38
+ "devDependencies": {
39
+ "eslint": "9.39.3",
40
+ "oxlint": "1.72.0",
41
+ "typescript": "6.0.3",
42
+ "vitest": "4.1.10"
43
+ },
44
+ "peerDependencies": {
45
+ "eslint": ">=9.0.0"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "eslint": {
49
+ "optional": true
50
+ }
51
+ },
52
+ "engines": {
53
+ "node": ">=22.14.0"
54
+ }
55
+ }
@@ -0,0 +1,36 @@
1
+ import { objectProperty, staticString } from "../lib/ast.js";
2
+ import { configureImportTracker, variableInitializer } from "../lib/sdk-bindings.js";
3
+
4
+ export default {
5
+ meta: {
6
+ type: "problem",
7
+ docs: {
8
+ description: "Disallow the external /api prefix in HTTP adapter path patterns.",
9
+ },
10
+ messages: {
11
+ prefixed: "pathPattern is matched after the /api prefix; remove the leading /api.",
12
+ },
13
+ schema: [],
14
+ },
15
+ create(context) {
16
+ const imports = configureImportTracker(context);
17
+ const calls = [];
18
+
19
+ return {
20
+ ImportDeclaration: (node) => imports.track(node),
21
+ CallExpression: (node) => calls.push(node),
22
+ "Program:exit"() {
23
+ for (const call of calls) {
24
+ if (imports.callName(call) !== "createHttpAdapter") continue;
25
+ let options = call.arguments[0];
26
+ if (options?.type === "Identifier") options = variableInitializer(context, options);
27
+ const property = objectProperty(options, "pathPattern");
28
+ if (!property || property.type !== "Property") continue;
29
+ const pattern = staticString(property.value);
30
+ if (pattern !== "/api" && !pattern?.startsWith("/api/")) continue;
31
+ context.report({ node: property.value, messageId: "prefixed" });
32
+ }
33
+ },
34
+ };
35
+ },
36
+ };
@@ -0,0 +1,196 @@
1
+ import { memberName, objectProperty, unwrapExpression } from "../lib/ast.js";
2
+ import {
3
+ configureImportTracker,
4
+ SDK_CONFIGURE_MODULE,
5
+ variableInitializer,
6
+ } from "../lib/sdk-bindings.js";
7
+
8
+ const UNSAFE_CONSTANTS = new Set([
9
+ "unsafeAllowAllTypePermission",
10
+ "unsafeAllowAllGqlPermission",
11
+ "unsafeAllowAllIdPPermission",
12
+ ]);
13
+
14
+ function isValueReference(node) {
15
+ const parent = node.parent;
16
+ if (!parent) return false;
17
+ switch (parent.type) {
18
+ case "ImportSpecifier":
19
+ case "ImportDefaultSpecifier":
20
+ case "ImportNamespaceSpecifier":
21
+ return false;
22
+ case "MemberExpression":
23
+ case "OptionalMemberExpression":
24
+ return parent.object === node || parent.computed;
25
+ case "Property":
26
+ return parent.value === node || parent.computed;
27
+ default:
28
+ return true;
29
+ }
30
+ }
31
+
32
+ function resolveValue(context, node) {
33
+ let current = unwrapExpression(node);
34
+ const seen = new Set();
35
+ while (current?.type === "Identifier" && !seen.has(current.name)) {
36
+ seen.add(current.name);
37
+ const initializer = variableInitializer(context, current);
38
+ if (initializer === null) break;
39
+ current = unwrapExpression(initializer);
40
+ }
41
+ return current;
42
+ }
43
+
44
+ function isDbReference(imports, node) {
45
+ const object = unwrapExpression(node);
46
+ if (object?.type === "Identifier") return imports.importedAs(object, "db");
47
+ if (object?.type === "MemberExpression" || object?.type === "OptionalMemberExpression") {
48
+ return memberName(object) === "db" && imports.isNamespace(unwrapExpression(object.object));
49
+ }
50
+ return false;
51
+ }
52
+
53
+ function isDbTypeReceiver(context, imports, node) {
54
+ let current = resolveValue(context, node);
55
+ while (current?.type === "CallExpression") {
56
+ const callee = unwrapExpression(current.callee);
57
+ if (callee?.type !== "MemberExpression" && callee?.type !== "OptionalMemberExpression") {
58
+ return false;
59
+ }
60
+ if (memberName(callee) === "type" && isDbReference(imports, callee.object)) return true;
61
+ current = resolveValue(context, callee.object);
62
+ }
63
+ return false;
64
+ }
65
+
66
+ function permissionArgument(context, imports, call) {
67
+ if (imports.callName(call) === "defineIdp") {
68
+ const options = resolveValue(context, call.arguments[1]);
69
+ const property = objectProperty(options, "permission");
70
+ return property?.type === "Property" ? property.value : null;
71
+ }
72
+ const callee = unwrapExpression(call.callee);
73
+ if (callee?.type !== "MemberExpression" && callee?.type !== "OptionalMemberExpression") {
74
+ return null;
75
+ }
76
+ const method = memberName(callee);
77
+ if (method !== "permission" && method !== "gqlPermission") return null;
78
+ if (!isDbTypeReceiver(context, imports, callee.object)) return null;
79
+ return call.arguments[0] ?? null;
80
+ }
81
+
82
+ function isUnconditionalObjectEntry(context, entry) {
83
+ const conditions = objectProperty(entry, "conditions");
84
+ const permit = objectProperty(entry, "permit");
85
+ if (conditions?.type !== "Property" || permit?.type !== "Property") return false;
86
+ const conditionsValue = resolveValue(context, conditions.value);
87
+ const permitValue = unwrapExpression(permit.value);
88
+ return (
89
+ conditionsValue?.type === "ArrayExpression" &&
90
+ conditionsValue.elements.length === 0 &&
91
+ permitValue?.type === "Literal" &&
92
+ permitValue.value === true
93
+ );
94
+ }
95
+
96
+ function isUnconditionalShorthandEntry(entry) {
97
+ let permit = true;
98
+ for (const element of entry.elements) {
99
+ if (element === null || element.type === "SpreadElement") return false;
100
+ const value = unwrapExpression(element);
101
+ if (value?.type !== "Literal" || typeof value.value !== "boolean") return false;
102
+ permit = value.value;
103
+ }
104
+ return permit;
105
+ }
106
+
107
+ function reportEntry(context, node) {
108
+ const entry = resolveValue(context, node);
109
+ if (
110
+ (entry?.type === "ObjectExpression" && isUnconditionalObjectEntry(context, entry)) ||
111
+ (entry?.type === "ArrayExpression" && isUnconditionalShorthandEntry(entry))
112
+ ) {
113
+ context.report({ node: entry, messageId: "unconditionalEntry" });
114
+ }
115
+ }
116
+
117
+ function reportEntryList(context, node) {
118
+ const entries = resolveValue(context, node);
119
+ if (entries?.type !== "ArrayExpression") return;
120
+ for (const element of entries.elements) {
121
+ if (element !== null && element.type !== "SpreadElement") reportEntry(context, element);
122
+ }
123
+ }
124
+
125
+ function reportUnconditionalEntries(context, node) {
126
+ const value = resolveValue(context, node);
127
+ if (value?.type === "ArrayExpression") {
128
+ reportEntryList(context, value);
129
+ return;
130
+ }
131
+ if (value?.type !== "ObjectExpression") return;
132
+ for (const property of value.properties) {
133
+ if (property.type === "Property") reportEntryList(context, property.value);
134
+ }
135
+ }
136
+
137
+ export default {
138
+ meta: {
139
+ type: "problem",
140
+ docs: {
141
+ description: "Disallow permission settings that grant access unconditionally.",
142
+ },
143
+ messages: {
144
+ unsafeConstant:
145
+ "{{name}} grants access unconditionally; define restrictive permission conditions instead.",
146
+ unconditionalEntry:
147
+ "This permission entry permits access without any conditions; add conditions or remove it.",
148
+ },
149
+ schema: [],
150
+ },
151
+ create(context) {
152
+ const imports = configureImportTracker(context);
153
+ const unsafeLocals = new Map();
154
+ const calls = [];
155
+ const identifiers = [];
156
+ const members = [];
157
+
158
+ return {
159
+ ImportDeclaration(node) {
160
+ imports.track(node);
161
+ if (node.source.value !== SDK_CONFIGURE_MODULE) return;
162
+ for (const specifier of node.specifiers) {
163
+ if (specifier.type !== "ImportSpecifier" || specifier.importKind === "type") continue;
164
+ const imported =
165
+ specifier.imported.type === "Identifier"
166
+ ? specifier.imported.name
167
+ : String(specifier.imported.value);
168
+ if (UNSAFE_CONSTANTS.has(imported)) unsafeLocals.set(specifier.local.name, imported);
169
+ }
170
+ },
171
+ CallExpression: (node) => calls.push(node),
172
+ Identifier: (node) => identifiers.push(node),
173
+ MemberExpression: (node) => {
174
+ if (UNSAFE_CONSTANTS.has(memberName(node) ?? "")) members.push(node);
175
+ },
176
+ "Program:exit"() {
177
+ for (const node of identifiers) {
178
+ const name = unsafeLocals.get(node.name);
179
+ if (name === undefined || !isValueReference(node) || !imports.importedAs(node, name)) {
180
+ continue;
181
+ }
182
+ context.report({ node, messageId: "unsafeConstant", data: { name } });
183
+ }
184
+ for (const node of members) {
185
+ if (!imports.isNamespace(unwrapExpression(node.object))) continue;
186
+ const name = memberName(node);
187
+ context.report({ node, messageId: "unsafeConstant", data: { name } });
188
+ }
189
+ for (const call of calls) {
190
+ const permission = permissionArgument(context, imports, call);
191
+ if (permission !== null) reportUnconditionalEntries(context, permission);
192
+ }
193
+ },
194
+ };
195
+ },
196
+ };