@gblikas/querykit 0.0.0 → 0.1.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.
@@ -18,18 +18,18 @@ jobs:
18
18
  runs-on: ubuntu-latest
19
19
  permissions:
20
20
  contents: read
21
+ id-token: write # Required for NPM Trusted Publishers (OIDC)
21
22
  steps:
22
23
  - name: Checkout code
23
24
  uses: actions/checkout@v4
24
25
  with:
25
- ref: ${{ github.event.release.tag_name }}
26
+ ref: ${{ github.event.release.tag_name || inputs.tag }}
26
27
 
27
28
  - name: Setup Node.js
28
29
  uses: actions/setup-node@v4
29
30
  with:
30
- node-version: '20'
31
+ node-version: '24'
31
32
  registry-url: 'https://registry.npmjs.org'
32
- always-auth: true
33
33
 
34
34
  - name: Setup pnpm
35
35
  uses: pnpm/action-setup@v3
@@ -53,9 +53,7 @@ jobs:
53
53
  TAG: ${{ github.event.release.tag_name || inputs.tag }}
54
54
  run: node -e 'const v=require("./package.json").version; const tag=(process.env.TAG||"").replace(/^v/,""); if(v!==tag){console.error("package.json version "+v+" does not match tag "+tag); process.exit(1)} else {console.log("Version matches tag:", v)}'
55
55
 
56
- - name: Publish package to npmjs
57
- env:
58
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
59
- run: pnpm publish --no-git-checks --access public
56
+ - name: Publish package to npmjs with provenance
57
+ run: pnpm publish --no-git-checks --access public --provenance
60
58
 
61
59
 
package/README.md CHANGED
@@ -124,6 +124,16 @@ const qk = createQueryKit({
124
124
  allowedFields: ['name', 'email', 'priority', 'status'], // Only these fields can be queried
125
125
  denyFields: ['password', 'secretKey'], // These fields can never be queried
126
126
 
127
+ // Value restrictions - deny specific values for fields
128
+ denyValues: {
129
+ status: ['deleted', 'banned'], // Block queries for deleted/banned records
130
+ role: ['superadmin', 'system'], // Prevent querying privileged roles
131
+ 'user.type': ['internal', 'bot'] // Supports dot-notation for nested fields
132
+ },
133
+
134
+ // Field name restrictions
135
+ allowDotNotation: true, // Set to false to block "table.field" or "json.path" queries
136
+
127
137
  // Query complexity limits
128
138
  maxQueryDepth: 5, // Maximum nesting level of expressions
129
139
  maxClauseCount: 20, // Maximum number of clauses (AND/OR operations)
@@ -150,6 +160,8 @@ const DEFAULT_SECURITY = {
150
160
  // Field restrictions - by default, all schema fields are allowed
151
161
  allowedFields: [], // Empty means "use schema fields"
152
162
  denyFields: [], // Empty means no denied fields
163
+ denyValues: {}, // Empty means no denied values for any field
164
+ allowDotNotation: true, // Allow "table.field" and "json.path" notation
153
165
 
154
166
  // Query complexity limits
155
167
  maxQueryDepth: 10, // Maximum nesting level of expressions
@@ -174,6 +186,10 @@ Security configurations can be stored in a separate file and imported:
174
186
  // security-config.json
175
187
  {
176
188
  "allowedFields": ["name", "email", "priority", "status"],
189
+ "denyValues": {
190
+ "status": ["deleted", "banned"],
191
+ "role": ["superadmin", "system"]
192
+ },
177
193
  "maxQueryDepth": 5,
178
194
  "maxClauseCount": 20,
179
195
  "defaultLimit": 100
@@ -199,6 +215,66 @@ When using QueryKit in production, consider these additional security practices:
199
215
  4. **Field-Level Access Control**: Use dynamic allowedFields based on user roles/permissions.
200
216
  5. **Separate Query Context**: Consider separate QueryKit instances with different security settings for different contexts (admin vs. user).
201
217
 
218
+ ### Controlling Dot Notation in Field Names
219
+
220
+ QueryKit supports dot notation in field names (e.g., `user.name`, `metadata.tags`) which is useful for:
221
+
222
+ - **Table-qualified columns**: When joining tables with overlapping column names (`users.id` vs `orders.id`)
223
+ - **JSON/JSONB fields**: Querying nested data in PostgreSQL JSON columns (`metadata.dimensions.width`)
224
+ - **Related data**: Accessing data through ORM relations (`order.customer.name`)
225
+
226
+ However, you may want to **disable dot notation** for public-facing APIs:
227
+
228
+ ```typescript
229
+ const qk = createQueryKit({
230
+ adapter: drizzleAdapter,
231
+ schema: { products },
232
+ security: {
233
+ allowDotNotation: false, // Reject queries like "user.password" or "config.secret"
234
+ allowedFields: ['name', 'price', 'category', 'inStock']
235
+ }
236
+ });
237
+
238
+ // ✅ Allowed: Simple field names
239
+ qk.query('products').where('name:"Widget" AND price:<100');
240
+
241
+ // ❌ Rejected: Dot notation
242
+ qk.query('products').where('user.password:"secret"');
243
+ // Error: Dot notation is not allowed in field names. Found "user.password" - use a simple field name without dots instead.
244
+ ```
245
+
246
+ **When to disable dot notation:**
247
+
248
+ | Scenario | Recommendation |
249
+ |----------|---------------|
250
+ | Public search API | Disable - prevents probing internal table structures |
251
+ | Admin dashboard | Enable - admins may need cross-table queries |
252
+ | Simple flat schema | Disable - simplifies security model |
253
+ | JSON/JSONB columns | Enable - needed for nested data access |
254
+ | Multi-tenant app | Disable - prevents `tenant.secret` style access |
255
+
256
+ **Concrete example - Public e-commerce search:**
257
+
258
+ ```typescript
259
+ // For a public product search endpoint, disable dot notation
260
+ // to prevent users from attempting queries like:
261
+ // - "orders.creditCard" (accessing other tables)
262
+ // - "internal.costPrice" (accessing internal JSON fields)
263
+ // - "admin.notes" (accessing admin-only data)
264
+
265
+ const publicSearchKit = createQueryKit({
266
+ adapter: drizzleAdapter,
267
+ schema: { products },
268
+ security: {
269
+ allowDotNotation: false,
270
+ allowedFields: ['name', 'description', 'price', 'category'],
271
+ denyValues: {
272
+ category: ['internal', 'discontinued']
273
+ }
274
+ }
275
+ });
276
+ ```
277
+
202
278
  ## Roadmap
203
279
 
204
280
  ### Core Parsing Engine and DSL
@@ -54,6 +54,54 @@ export interface ISecurityOptions {
54
54
  * ```
55
55
  */
56
56
  denyFields?: string[];
57
+ /**
58
+ * Map of field names to arrays of values that are denied for that field.
59
+ * This provides granular control over what values can be used in queries.
60
+ * Use this to protect against queries targeting specific sensitive values.
61
+ *
62
+ * The keys are field names (can include table prefixes like "user.role")
63
+ * and the values are arrays of denied values for that field.
64
+ *
65
+ * @example
66
+ * ```typescript
67
+ * // Prevent certain values from being queried
68
+ * denyValues: {
69
+ * 'status': ['deleted', 'banned'],
70
+ * 'role': ['superadmin', 'system'],
71
+ * 'user.type': ['internal', 'bot']
72
+ * }
73
+ *
74
+ * // This would block queries like:
75
+ * // status == "deleted"
76
+ * // role IN ["superadmin", "admin"]
77
+ * // user.type == "internal"
78
+ * ```
79
+ */
80
+ denyValues?: Record<string, Array<string | number | boolean | null>>;
81
+ /**
82
+ * Whether to allow dot notation in field names (e.g., "user.name", "metadata.tags").
83
+ * When disabled, queries with dots in field names will be rejected.
84
+ *
85
+ * Use cases for DISABLING dot notation:
86
+ * - Public-facing search APIs where users should only query flat, top-level fields
87
+ * - Preventing access to table-qualified columns in SQL joins (e.g., "users.password")
88
+ * - Simpler security model when your schema doesn't have nested/JSON data
89
+ * - Preventing users from probing internal table structures
90
+ *
91
+ * @default true
92
+ *
93
+ * @example
94
+ * ```typescript
95
+ * // Disable dot notation for a public search API
96
+ * allowDotNotation: false
97
+ *
98
+ * // This would block queries like:
99
+ * // user.email == "test@example.com" // Rejected
100
+ * // metadata.tags == "sale" // Rejected
101
+ * // email == "test@example.com" // Allowed
102
+ * ```
103
+ */
104
+ allowDotNotation?: boolean;
57
105
  /**
58
106
  * Maximum nesting depth of query expressions.
59
107
  * Prevents deeply nested queries that could impact performance.
@@ -29,6 +29,8 @@ exports.DEFAULT_SECURITY_OPTIONS = {
29
29
  // Field restrictions - by default, all schema fields are allowed
30
30
  allowedFields: [], // Empty means "use schema fields"
31
31
  denyFields: [], // Empty means no denied fields
32
+ denyValues: {}, // Empty means no denied values for any field
33
+ allowDotNotation: true, // Allow dot notation by default for backward compatibility
32
34
  // Query complexity limits
33
35
  maxQueryDepth: 10, // Maximum nesting level of expressions
34
36
  maxClauseCount: 50, // Maximum number of clauses (AND/OR operations)
@@ -141,6 +141,41 @@ export declare class QuerySecurityValidator {
141
141
  * @param schema - Optional schema definition to validate fields against
142
142
  */
143
143
  private validateFields;
144
+ /**
145
+ * Validate that field names do not contain dot notation
146
+ *
147
+ * When allowDotNotation is disabled, this method ensures no field names
148
+ * contain dots, which could be used for:
149
+ * - Table-qualified column access (e.g., "users.password")
150
+ * - Nested JSON/JSONB field access (e.g., "metadata.secret")
151
+ * - Probing internal table structures
152
+ *
153
+ * @private
154
+ * @param expression - The query expression to validate
155
+ * @throws {QuerySecurityError} If a field name contains dot notation
156
+ */
157
+ private validateNoDotNotation;
158
+ /**
159
+ * Validate that query values are not in the denied values list for their field
160
+ *
161
+ * This method checks each comparison expression to ensure the value being
162
+ * queried is not in the denyValues list for that field. This provides
163
+ * granular control over what values can be queried for specific fields.
164
+ *
165
+ * @private
166
+ * @param expression - The query expression to validate
167
+ * @throws {QuerySecurityError} If a denied value is found in the query
168
+ */
169
+ private validateDenyValues;
170
+ /**
171
+ * Check if a value is in the denied values list
172
+ *
173
+ * @private
174
+ * @param value - The value to check
175
+ * @param deniedValues - The list of denied values
176
+ * @returns true if the value is denied, false otherwise
177
+ */
178
+ private isValueDenied;
144
179
  /**
145
180
  * Validate that query depth does not exceed the maximum
146
181
  *
@@ -143,8 +143,14 @@ class QuerySecurityValidator {
143
143
  * ```
144
144
  */
145
145
  validate(expression, schema) {
146
+ // Check for dot notation if disabled
147
+ if (!this.options.allowDotNotation) {
148
+ this.validateNoDotNotation(expression);
149
+ }
146
150
  // Check for field restrictions if specified
147
151
  this.validateFields(expression, schema);
152
+ // Check for denied values if specified
153
+ this.validateDenyValues(expression);
148
154
  // Check query complexity
149
155
  this.validateQueryDepth(expression, 0);
150
156
  this.validateClauseCount(expression);
@@ -192,6 +198,108 @@ class QuerySecurityValidator {
192
198
  }
193
199
  }
194
200
  }
201
+ /**
202
+ * Validate that field names do not contain dot notation
203
+ *
204
+ * When allowDotNotation is disabled, this method ensures no field names
205
+ * contain dots, which could be used for:
206
+ * - Table-qualified column access (e.g., "users.password")
207
+ * - Nested JSON/JSONB field access (e.g., "metadata.secret")
208
+ * - Probing internal table structures
209
+ *
210
+ * @private
211
+ * @param expression - The query expression to validate
212
+ * @throws {QuerySecurityError} If a field name contains dot notation
213
+ */
214
+ validateNoDotNotation(expression) {
215
+ if (expression.type === 'comparison') {
216
+ const { field } = expression;
217
+ if (field.includes('.')) {
218
+ throw new QuerySecurityError(`Dot notation is not allowed in field names. ` +
219
+ `Found "${field}" - use a simple field name without dots instead.`);
220
+ }
221
+ }
222
+ else {
223
+ // Recursively validate logical expressions
224
+ this.validateNoDotNotation(expression.left);
225
+ if (expression.right) {
226
+ this.validateNoDotNotation(expression.right);
227
+ }
228
+ }
229
+ }
230
+ /**
231
+ * Validate that query values are not in the denied values list for their field
232
+ *
233
+ * This method checks each comparison expression to ensure the value being
234
+ * queried is not in the denyValues list for that field. This provides
235
+ * granular control over what values can be queried for specific fields.
236
+ *
237
+ * @private
238
+ * @param expression - The query expression to validate
239
+ * @throws {QuerySecurityError} If a denied value is found in the query
240
+ */
241
+ validateDenyValues(expression) {
242
+ // Skip if no denyValues configured
243
+ if (Object.keys(this.options.denyValues).length === 0) {
244
+ return;
245
+ }
246
+ if (expression.type === 'comparison') {
247
+ const { field, value } = expression;
248
+ const deniedValues = this.options.denyValues[field];
249
+ if (deniedValues && deniedValues.length > 0) {
250
+ // Check if the value is an array (for IN/NOT IN operators)
251
+ if (Array.isArray(value)) {
252
+ for (const item of value) {
253
+ if (this.isValueDenied(item, deniedValues)) {
254
+ throw new QuerySecurityError('Invalid query parameters');
255
+ }
256
+ }
257
+ }
258
+ else {
259
+ // Single value comparison
260
+ if (this.isValueDenied(value, deniedValues)) {
261
+ throw new QuerySecurityError('Invalid query parameters');
262
+ }
263
+ }
264
+ }
265
+ }
266
+ else {
267
+ // Recursively validate logical expressions
268
+ this.validateDenyValues(expression.left);
269
+ if (expression.right) {
270
+ this.validateDenyValues(expression.right);
271
+ }
272
+ }
273
+ }
274
+ /**
275
+ * Check if a value is in the denied values list
276
+ *
277
+ * @private
278
+ * @param value - The value to check
279
+ * @param deniedValues - The list of denied values
280
+ * @returns true if the value is denied, false otherwise
281
+ */
282
+ isValueDenied(value, deniedValues) {
283
+ // Use strict equality to match values, handling type coercion properly
284
+ return deniedValues.some(deniedValue => {
285
+ // Handle null comparison explicitly
286
+ if (value === null && deniedValue === null) {
287
+ return true;
288
+ }
289
+ // Handle same-type comparison with strict equality
290
+ if (typeof value === typeof deniedValue) {
291
+ return value === deniedValue;
292
+ }
293
+ // Handle string/number comparison (common case)
294
+ if (typeof value === 'string' && typeof deniedValue === 'number') {
295
+ return value === String(deniedValue);
296
+ }
297
+ if (typeof value === 'number' && typeof deniedValue === 'string') {
298
+ return String(value) === deniedValue;
299
+ }
300
+ return false;
301
+ });
302
+ }
195
303
  /**
196
304
  * Validate that query depth does not exceed the maximum
197
305
  *
@@ -116,7 +116,30 @@
116
116
  * {
117
117
  @apply border-border outline-ring/50;
118
118
  }
119
+
119
120
  body {
120
121
  @apply bg-background text-foreground;
122
+ overscroll-behavior: none;
123
+ overflow: hidden;
124
+ }
125
+ }
126
+
127
+ @layer components {
128
+ .quick-start-snippet {
129
+ @apply !m-0 !bg-transparent whitespace-pre-wrap break-words px-3 py-3 pr-14 text-xs leading-[1.4] font-mono sm:text-sm;
121
130
  }
131
+ }
132
+
133
+ /* Prevent mobile zoom on inputs by ensuring font-size >= 16px */
134
+ input,
135
+ textarea,
136
+ select {
137
+ font-size: 16px;
138
+ }
139
+
140
+ /* Ensure the root takes full viewport and no scrollbars appear */
141
+ html,
142
+ body,
143
+ #__next {
144
+ height: 100%;
122
145
  }
@@ -0,0 +1,89 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+
5
+ export interface IViewportInfo {
6
+ innerWidth: number;
7
+ innerHeight: number;
8
+ shortViewportHeightPx: number;
9
+ isLessThanHeight: (px: number) => boolean;
10
+ shortSidePx: number;
11
+ isShortSideLessThan: (px: number) => boolean;
12
+ }
13
+
14
+ /**
15
+ * Returns robust viewport dimensions using small viewport units (svh/svw) fallbacks.
16
+ * Ensures height reflects the visual viewport on mobile, avoiding URL bar issues.
17
+ */
18
+ export function useViewportInfo(): IViewportInfo {
19
+ const readInnerHeight = (): number => {
20
+ // Prefer visualViewport when available to avoid browser UI chrome affecting measurements
21
+ const visual = window.visualViewport;
22
+ if (visual && typeof visual.height === 'number')
23
+ return Math.round(visual.height);
24
+ // Fallbacks in order of reliability
25
+ return Math.round(
26
+ window.innerHeight || document.documentElement.clientHeight
27
+ );
28
+ };
29
+ const readInnerWidth = (): number => {
30
+ const visual = window.visualViewport;
31
+ if (visual && typeof visual.width === 'number')
32
+ return Math.round(visual.width);
33
+ return Math.round(
34
+ window.innerWidth || document.documentElement.clientWidth
35
+ );
36
+ };
37
+
38
+ const [state, setState] = useState<IViewportInfo>((): IViewportInfo => {
39
+ const w =
40
+ typeof window !== 'undefined'
41
+ ? typeof window.visualViewport?.width === 'number'
42
+ ? Math.round(window.visualViewport.width)
43
+ : window.innerWidth
44
+ : 0;
45
+ const h = typeof window !== 'undefined' ? readInnerHeight() : 0;
46
+ const shortSide = Math.min(w, h);
47
+ return {
48
+ innerWidth: w,
49
+ innerHeight: h,
50
+ shortViewportHeightPx: h,
51
+ isLessThanHeight: (px: number) => h < px,
52
+ shortSidePx: shortSide,
53
+ isShortSideLessThan: (px: number) => shortSide < px
54
+ };
55
+ });
56
+
57
+ useEffect(() => {
58
+ let frame = 0;
59
+ const measure = (): void => {
60
+ const w = readInnerWidth();
61
+ const h = readInnerHeight();
62
+ const shortSide = Math.min(w, h);
63
+ setState({
64
+ innerWidth: w,
65
+ innerHeight: h,
66
+ shortViewportHeightPx: h,
67
+ isLessThanHeight: (px: number) => h < px,
68
+ shortSidePx: shortSide,
69
+ isShortSideLessThan: (px: number) => shortSide < px
70
+ });
71
+ };
72
+ frame = requestAnimationFrame(measure);
73
+ const onResize = (): void => {
74
+ cancelAnimationFrame(frame);
75
+ frame = requestAnimationFrame(measure);
76
+ };
77
+ window.addEventListener('resize', onResize);
78
+ window.visualViewport?.addEventListener('resize', onResize);
79
+ window.visualViewport?.addEventListener('scroll', onResize);
80
+ return (): void => {
81
+ cancelAnimationFrame(frame);
82
+ window.removeEventListener('resize', onResize);
83
+ window.visualViewport?.removeEventListener('resize', onResize);
84
+ window.visualViewport?.removeEventListener('scroll', onResize);
85
+ };
86
+ }, []);
87
+
88
+ return state;
89
+ }
@@ -1,4 +1,4 @@
1
- import type { Metadata } from 'next';
1
+ import type { Metadata, Viewport } from 'next';
2
2
  import { Geist, Geist_Mono } from 'next/font/google';
3
3
  import './globals.css';
4
4
  import { Toaster } from '@/components/ui/sonner';
@@ -7,6 +7,8 @@ import { ThemeProvider } from '@/components/theme-provider';
7
7
  import { GitHubStars } from '@/components/github-stars';
8
8
  import AuroraBackground from '@/components/aurora-background';
9
9
  import { JSX } from 'react';
10
+ import { Analytics } from '@vercel/analytics/next';
11
+ import { SpeedInsights } from '@vercel/speed-insights/next';
10
12
 
11
13
  const geistSans = Geist({
12
14
  variable: '--font-geist-sans',
@@ -19,9 +21,7 @@ const geistMono = Geist_Mono({
19
21
  });
20
22
 
21
23
  export const metadata: Metadata = {
22
- metadataBase: new URL(
23
- process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'
24
- ),
24
+ metadataBase: new URL('https://www.querykit.dev/'),
25
25
  title: {
26
26
  default: 'QueryKit · Next.js Demo',
27
27
  template: '%s · QueryKit'
@@ -37,7 +37,12 @@ export const metadata: Metadata = {
37
37
  'drizzle orm',
38
38
  'pglite',
39
39
  'typescript',
40
- 'next.js'
40
+ 'next.js',
41
+ 'drizzle',
42
+ 'sql',
43
+ 'npm',
44
+ 'npm-package',
45
+ 'nextjs'
41
46
  ],
42
47
  authors: [{ name: 'gblikas', url: 'https://github.com/gblikas' }],
43
48
  creator: 'QueryKit',
@@ -79,15 +84,27 @@ export const metadata: Metadata = {
79
84
  ]
80
85
  };
81
86
 
87
+ export const viewport: Viewport = {
88
+ width: 'device-width',
89
+ initialScale: 1,
90
+ maximumScale: 1,
91
+ userScalable: false,
92
+ viewportFit: 'cover'
93
+ };
94
+
82
95
  export default function RootLayout({
83
96
  children
84
97
  }: {
85
98
  children: React.ReactNode;
86
99
  }): JSX.Element {
87
100
  return (
88
- <html lang="en" suppressHydrationWarning className="min-h-screen">
101
+ <html
102
+ lang="en"
103
+ suppressHydrationWarning
104
+ className="min-h-screen overflow-hidden"
105
+ >
89
106
  <body
90
- className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-screen`}
107
+ className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-screen overflow-hidden`}
91
108
  >
92
109
  <ThemeProvider
93
110
  attribute="class"
@@ -113,6 +130,8 @@ export default function RootLayout({
113
130
  </div>
114
131
  <Providers>{children}</Providers>
115
132
  <Toaster />
133
+ <Analytics />
134
+ <SpeedInsights />
116
135
  </div>
117
136
  </ThemeProvider>
118
137
  </body>