@hypequery/serve 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/LICENSE +201 -0
- package/README.md +1039 -0
- package/dist/adapters/fetch.d.ts +3 -0
- package/dist/adapters/fetch.d.ts.map +1 -0
- package/dist/adapters/fetch.js +26 -0
- package/dist/adapters/node.d.ts +8 -0
- package/dist/adapters/node.d.ts.map +1 -0
- package/dist/adapters/node.js +105 -0
- package/dist/adapters/utils.d.ts +39 -0
- package/dist/adapters/utils.d.ts.map +1 -0
- package/dist/adapters/utils.js +114 -0
- package/dist/adapters/vercel.d.ts +7 -0
- package/dist/adapters/vercel.d.ts.map +1 -0
- package/dist/adapters/vercel.js +13 -0
- package/dist/auth.d.ts +14 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +37 -0
- package/dist/client-config.d.ts +44 -0
- package/dist/client-config.d.ts.map +1 -0
- package/dist/client-config.js +53 -0
- package/dist/dev.d.ts +9 -0
- package/dist/dev.d.ts.map +1 -0
- package/dist/dev.js +24 -0
- package/dist/docs-ui.d.ts +3 -0
- package/dist/docs-ui.d.ts.map +1 -0
- package/dist/docs-ui.js +34 -0
- package/dist/endpoint.d.ts +5 -0
- package/dist/endpoint.d.ts.map +1 -0
- package/dist/endpoint.js +58 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/openapi.d.ts +3 -0
- package/dist/openapi.d.ts.map +1 -0
- package/dist/openapi.js +189 -0
- package/dist/queries.d.ts +3 -0
- package/dist/queries.d.ts.map +1 -0
- package/dist/queries.js +1 -0
- package/dist/query.d.ts +4 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +1 -0
- package/dist/router.d.ts +13 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +56 -0
- package/dist/sdk-generator.d.ts +7 -0
- package/dist/sdk-generator.d.ts.map +1 -0
- package/dist/sdk-generator.js +143 -0
- package/dist/server.d.ts +9 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +580 -0
- package/dist/tenant.d.ts +35 -0
- package/dist/tenant.d.ts.map +1 -0
- package/dist/tenant.js +49 -0
- package/dist/type-tests/builder.test-d.d.ts +13 -0
- package/dist/type-tests/builder.test-d.d.ts.map +1 -0
- package/dist/type-tests/builder.test-d.js +20 -0
- package/dist/types.d.ts +363 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +50 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
3
|
+
import { startNodeServer } from "./adapters/node.js";
|
|
4
|
+
import { createEndpoint } from "./endpoint.js";
|
|
5
|
+
import { buildOpenApiDocument } from "./openapi.js";
|
|
6
|
+
import { applyBasePath, normalizeRoutePath, ServeRouter } from "./router.js";
|
|
7
|
+
import { buildDocsHtml } from "./docs-ui.js";
|
|
8
|
+
import { createTenantScope, warnTenantMisconfiguration } from "./tenant.js";
|
|
9
|
+
const ensureArray = (value) => {
|
|
10
|
+
if (!value) {
|
|
11
|
+
return [];
|
|
12
|
+
}
|
|
13
|
+
return Array.isArray(value) ? value : [value];
|
|
14
|
+
};
|
|
15
|
+
const generateRequestId = () => {
|
|
16
|
+
return `req_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
|
17
|
+
};
|
|
18
|
+
const createProcedureBuilder = () => {
|
|
19
|
+
const build = (state) => {
|
|
20
|
+
return {
|
|
21
|
+
input: (schema) => build({ ...state, inputSchema: schema }),
|
|
22
|
+
output: (schema) => build({ ...state, outputSchema: schema }),
|
|
23
|
+
describe: (description) => build({ ...state, description }),
|
|
24
|
+
summary: (summary) => build({ ...state, summary }),
|
|
25
|
+
tag: (tag) => build({
|
|
26
|
+
...state,
|
|
27
|
+
tags: Array.from(new Set([...state.tags, tag])),
|
|
28
|
+
}),
|
|
29
|
+
tags: (tags) => build({
|
|
30
|
+
...state,
|
|
31
|
+
tags: Array.from(new Set([...state.tags, ...(tags ?? [])])),
|
|
32
|
+
}),
|
|
33
|
+
method: (method) => build({ ...state, method }),
|
|
34
|
+
cache: (ttlMs) => build({ ...state, cacheTtlMs: ttlMs }),
|
|
35
|
+
auth: (strategy) => build({ ...state, auth: strategy }),
|
|
36
|
+
tenant: (config) => build({ ...state, tenant: config }),
|
|
37
|
+
custom: (custom) => build({
|
|
38
|
+
...state,
|
|
39
|
+
custom: { ...(state.custom ?? {}), ...custom },
|
|
40
|
+
}),
|
|
41
|
+
use: (...middlewares) => build({
|
|
42
|
+
...state,
|
|
43
|
+
middlewares: [...state.middlewares, ...middlewares],
|
|
44
|
+
}),
|
|
45
|
+
query: (executable) => {
|
|
46
|
+
const base = {
|
|
47
|
+
description: state.description,
|
|
48
|
+
summary: state.summary,
|
|
49
|
+
tags: state.tags,
|
|
50
|
+
method: state.method,
|
|
51
|
+
inputSchema: state.inputSchema,
|
|
52
|
+
outputSchema: state.outputSchema,
|
|
53
|
+
cacheTtlMs: state.cacheTtlMs,
|
|
54
|
+
auth: typeof state.auth === "undefined" ? null : state.auth,
|
|
55
|
+
tenant: state.tenant,
|
|
56
|
+
custom: state.custom,
|
|
57
|
+
middlewares: state.middlewares,
|
|
58
|
+
query: executable,
|
|
59
|
+
};
|
|
60
|
+
return base;
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
return build({ tags: [], middlewares: [] });
|
|
65
|
+
};
|
|
66
|
+
const getRequestId = (request) => {
|
|
67
|
+
return (request.headers["x-request-id"] ??
|
|
68
|
+
request.headers["x-trace-id"] ??
|
|
69
|
+
generateRequestId());
|
|
70
|
+
};
|
|
71
|
+
const safeInvokeHook = async (name, hook, payload) => {
|
|
72
|
+
if (!hook) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
await hook(payload);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
console.error(`[hypequery/serve] ${name} hook failed`, error);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
const mergeTags = (existing, next) => {
|
|
83
|
+
const merged = [...existing, ...(next ?? [])];
|
|
84
|
+
return Array.from(new Set(merged.filter(Boolean)));
|
|
85
|
+
};
|
|
86
|
+
const createErrorResponse = (status, type, message, details) => {
|
|
87
|
+
return {
|
|
88
|
+
status,
|
|
89
|
+
body: {
|
|
90
|
+
error: {
|
|
91
|
+
type,
|
|
92
|
+
message,
|
|
93
|
+
...(details ? { details } : {}),
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
const buildContextInput = (request) => {
|
|
99
|
+
if (request.body !== undefined && request.body !== null) {
|
|
100
|
+
return request.body;
|
|
101
|
+
}
|
|
102
|
+
if (request.query && Object.keys(request.query).length > 0) {
|
|
103
|
+
return request.query;
|
|
104
|
+
}
|
|
105
|
+
return {};
|
|
106
|
+
};
|
|
107
|
+
const runMiddlewares = async (middlewares, ctx, handler) => {
|
|
108
|
+
let index = middlewares.length - 1;
|
|
109
|
+
let next = handler;
|
|
110
|
+
while (index >= 0) {
|
|
111
|
+
const middleware = middlewares[index];
|
|
112
|
+
const downstream = next;
|
|
113
|
+
next = () => middleware(ctx, downstream);
|
|
114
|
+
index -= 1;
|
|
115
|
+
}
|
|
116
|
+
return next();
|
|
117
|
+
};
|
|
118
|
+
const authenticateRequest = async (strategies, request, metadata) => {
|
|
119
|
+
for (const strategy of strategies) {
|
|
120
|
+
const result = await strategy({
|
|
121
|
+
request,
|
|
122
|
+
endpoint: metadata,
|
|
123
|
+
});
|
|
124
|
+
if (result) {
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
};
|
|
130
|
+
const gatherAuthStrategies = (endpointStrategy, globalStrategies) => {
|
|
131
|
+
const strategies = [];
|
|
132
|
+
if (endpointStrategy) {
|
|
133
|
+
strategies.push(endpointStrategy);
|
|
134
|
+
}
|
|
135
|
+
return [...strategies, ...globalStrategies];
|
|
136
|
+
};
|
|
137
|
+
const computeRequiresAuth = (metadata, endpointStrategy, globalStrategies) => {
|
|
138
|
+
if (typeof metadata.requiresAuth === "boolean") {
|
|
139
|
+
return metadata.requiresAuth;
|
|
140
|
+
}
|
|
141
|
+
if (endpointStrategy) {
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
return globalStrategies.length > 0;
|
|
145
|
+
};
|
|
146
|
+
const validateInput = (schema, payload) => {
|
|
147
|
+
if (!schema) {
|
|
148
|
+
return { success: true, data: payload };
|
|
149
|
+
}
|
|
150
|
+
const result = schema.safeParse(payload);
|
|
151
|
+
return result.success
|
|
152
|
+
? { success: true, data: result.data }
|
|
153
|
+
: { success: false, error: result.error };
|
|
154
|
+
};
|
|
155
|
+
const createOpenApiEndpoint = (path, getEndpoints, openapiOptions) => {
|
|
156
|
+
// Cache the OpenAPI document to avoid rebuilding on every request
|
|
157
|
+
let cachedDocument = null;
|
|
158
|
+
return {
|
|
159
|
+
key: "__hypequery_openapi__",
|
|
160
|
+
method: "GET",
|
|
161
|
+
inputSchema: undefined,
|
|
162
|
+
outputSchema: z.any(),
|
|
163
|
+
handler: async () => {
|
|
164
|
+
if (!cachedDocument) {
|
|
165
|
+
cachedDocument = buildOpenApiDocument(getEndpoints(), openapiOptions);
|
|
166
|
+
}
|
|
167
|
+
return cachedDocument;
|
|
168
|
+
},
|
|
169
|
+
query: undefined,
|
|
170
|
+
middlewares: [],
|
|
171
|
+
auth: null,
|
|
172
|
+
metadata: {
|
|
173
|
+
path,
|
|
174
|
+
method: "GET",
|
|
175
|
+
summary: "OpenAPI schema",
|
|
176
|
+
description: "Generated OpenAPI specification for the registered endpoints",
|
|
177
|
+
tags: ["docs"],
|
|
178
|
+
requiresAuth: false,
|
|
179
|
+
deprecated: false,
|
|
180
|
+
visibility: "internal",
|
|
181
|
+
},
|
|
182
|
+
cacheTtlMs: null,
|
|
183
|
+
};
|
|
184
|
+
};
|
|
185
|
+
const createDocsEndpoint = (path, openapiPath, options) => ({
|
|
186
|
+
key: "__hypequery_docs__",
|
|
187
|
+
method: "GET",
|
|
188
|
+
inputSchema: undefined,
|
|
189
|
+
outputSchema: z.string(),
|
|
190
|
+
handler: async () => buildDocsHtml(openapiPath, options),
|
|
191
|
+
query: undefined,
|
|
192
|
+
middlewares: [],
|
|
193
|
+
auth: null,
|
|
194
|
+
metadata: {
|
|
195
|
+
path,
|
|
196
|
+
method: "GET",
|
|
197
|
+
summary: "API documentation",
|
|
198
|
+
description: "Auto-generated documentation for your hypequery endpoints",
|
|
199
|
+
tags: ["docs"],
|
|
200
|
+
requiresAuth: false,
|
|
201
|
+
deprecated: false,
|
|
202
|
+
visibility: "internal",
|
|
203
|
+
},
|
|
204
|
+
cacheTtlMs: null,
|
|
205
|
+
defaultHeaders: {
|
|
206
|
+
"content-type": "text/html; charset=utf-8",
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
const cloneContext = (ctx) => (ctx ? { ...ctx } : {});
|
|
210
|
+
const resolveContext = async (factory, request, auth) => {
|
|
211
|
+
if (!factory) {
|
|
212
|
+
return {};
|
|
213
|
+
}
|
|
214
|
+
if (typeof factory === "function") {
|
|
215
|
+
const value = await factory({ request, auth });
|
|
216
|
+
return cloneContext(value);
|
|
217
|
+
}
|
|
218
|
+
return cloneContext(factory);
|
|
219
|
+
};
|
|
220
|
+
const executeEndpoint = async (options) => {
|
|
221
|
+
const { endpoint, request, requestId, authStrategies, contextFactory, globalMiddlewares, globalTenantConfig, hooks, additionalContext, } = options;
|
|
222
|
+
const locals = {};
|
|
223
|
+
let cacheTtlMs = endpoint.cacheTtlMs ?? null;
|
|
224
|
+
const setCacheTtl = (ttl) => {
|
|
225
|
+
cacheTtlMs = ttl;
|
|
226
|
+
};
|
|
227
|
+
const context = {
|
|
228
|
+
request,
|
|
229
|
+
input: buildContextInput(request),
|
|
230
|
+
auth: null,
|
|
231
|
+
metadata: endpoint.metadata,
|
|
232
|
+
locals,
|
|
233
|
+
setCacheTtl,
|
|
234
|
+
};
|
|
235
|
+
const startedAt = Date.now();
|
|
236
|
+
await safeInvokeHook("onRequestStart", hooks.onRequestStart, {
|
|
237
|
+
requestId,
|
|
238
|
+
queryKey: endpoint.key,
|
|
239
|
+
metadata: endpoint.metadata,
|
|
240
|
+
request,
|
|
241
|
+
auth: context.auth,
|
|
242
|
+
});
|
|
243
|
+
try {
|
|
244
|
+
const endpointAuth = endpoint.auth ?? null;
|
|
245
|
+
const strategies = gatherAuthStrategies(endpointAuth, authStrategies);
|
|
246
|
+
const requiresAuth = computeRequiresAuth(endpoint.metadata, endpointAuth, authStrategies);
|
|
247
|
+
const metadataWithAuth = {
|
|
248
|
+
...endpoint.metadata,
|
|
249
|
+
requiresAuth,
|
|
250
|
+
};
|
|
251
|
+
context.metadata = metadataWithAuth;
|
|
252
|
+
const authContext = await authenticateRequest(strategies, request, metadataWithAuth);
|
|
253
|
+
if (!authContext && requiresAuth) {
|
|
254
|
+
await safeInvokeHook("onAuthFailure", hooks.onAuthFailure, {
|
|
255
|
+
requestId,
|
|
256
|
+
queryKey: endpoint.key,
|
|
257
|
+
metadata: metadataWithAuth,
|
|
258
|
+
request,
|
|
259
|
+
auth: context.auth,
|
|
260
|
+
reason: "MISSING",
|
|
261
|
+
});
|
|
262
|
+
return createErrorResponse(401, "UNAUTHORIZED", "Authentication required", {
|
|
263
|
+
reason: "missing_credentials",
|
|
264
|
+
strategies_attempted: strategies.length,
|
|
265
|
+
endpoint: endpoint.metadata.path,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
// After the auth check above, if requiresAuth is true, authContext is guaranteed to be non-null
|
|
269
|
+
context.auth = authContext;
|
|
270
|
+
const hydratedContext = await resolveContext(contextFactory, request, authContext);
|
|
271
|
+
Object.assign(context, hydratedContext, additionalContext);
|
|
272
|
+
// Tenant isolation: Extract and validate tenant ID if configured
|
|
273
|
+
// Use endpoint-specific config, or fall back to global config
|
|
274
|
+
const tenantConfig = endpoint.tenant ?? globalTenantConfig;
|
|
275
|
+
if (tenantConfig) {
|
|
276
|
+
const tenantRequired = tenantConfig.required !== false; // Default to true
|
|
277
|
+
const tenantId = authContext ? tenantConfig.extract(authContext) : null;
|
|
278
|
+
if (!tenantId && tenantRequired) {
|
|
279
|
+
const errorMessage = tenantConfig.errorMessage ??
|
|
280
|
+
"Tenant context is required but could not be determined from authentication";
|
|
281
|
+
await safeInvokeHook("onError", hooks.onError, {
|
|
282
|
+
requestId,
|
|
283
|
+
queryKey: endpoint.key,
|
|
284
|
+
metadata: metadataWithAuth,
|
|
285
|
+
request,
|
|
286
|
+
auth: context.auth,
|
|
287
|
+
durationMs: Date.now() - startedAt,
|
|
288
|
+
error: new Error(errorMessage),
|
|
289
|
+
});
|
|
290
|
+
return createErrorResponse(403, "UNAUTHORIZED", errorMessage, {
|
|
291
|
+
reason: "missing_tenant_context",
|
|
292
|
+
tenant_required: true,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
if (tenantId) {
|
|
296
|
+
context.tenantId = tenantId;
|
|
297
|
+
// Auto-inject tenant filtering if configured
|
|
298
|
+
const mode = tenantConfig.mode ?? 'manual'; // Default to manual for backward compatibility
|
|
299
|
+
const column = tenantConfig.column;
|
|
300
|
+
if (mode === 'auto-inject' && column) {
|
|
301
|
+
// Wrap all query builders in the context to auto-inject tenant filters
|
|
302
|
+
const contextValues = context;
|
|
303
|
+
for (const key of Object.keys(contextValues)) {
|
|
304
|
+
const value = contextValues[key];
|
|
305
|
+
// Check if it looks like a query builder (has a table method)
|
|
306
|
+
if (value && typeof value === 'object' && 'table' in value && typeof value.table === 'function') {
|
|
307
|
+
contextValues[key] = createTenantScope(value, { tenantId, column });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
else if (mode === 'manual') {
|
|
312
|
+
// Warn developers in manual mode to ensure they manually filter
|
|
313
|
+
warnTenantMisconfiguration({
|
|
314
|
+
queryKey: endpoint.key,
|
|
315
|
+
hasTenantConfig: true,
|
|
316
|
+
hasTenantId: true,
|
|
317
|
+
mode: 'manual',
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
else if (tenantConfig && !tenantRequired) {
|
|
322
|
+
// Optional tenant mode - warn if no tenant config when accessing user data
|
|
323
|
+
warnTenantMisconfiguration({
|
|
324
|
+
queryKey: endpoint.key,
|
|
325
|
+
hasTenantConfig: true,
|
|
326
|
+
hasTenantId: false,
|
|
327
|
+
mode: tenantConfig.mode,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const validationResult = validateInput(endpoint.inputSchema, context.input);
|
|
332
|
+
if (!validationResult.success) {
|
|
333
|
+
await safeInvokeHook("onError", hooks.onError, {
|
|
334
|
+
requestId,
|
|
335
|
+
queryKey: endpoint.key,
|
|
336
|
+
metadata: metadataWithAuth,
|
|
337
|
+
request,
|
|
338
|
+
auth: context.auth,
|
|
339
|
+
durationMs: Date.now() - startedAt,
|
|
340
|
+
error: validationResult.error,
|
|
341
|
+
});
|
|
342
|
+
return createErrorResponse(400, "VALIDATION_ERROR", "Request validation failed", {
|
|
343
|
+
issues: validationResult.error.issues,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
context.input = validationResult.data;
|
|
347
|
+
const pipeline = [
|
|
348
|
+
...globalMiddlewares,
|
|
349
|
+
...endpoint.middlewares,
|
|
350
|
+
];
|
|
351
|
+
const result = await runMiddlewares(pipeline, context, () => endpoint.handler(context));
|
|
352
|
+
const headers = { ...(endpoint.defaultHeaders ?? {}) };
|
|
353
|
+
if (typeof cacheTtlMs === "number") {
|
|
354
|
+
headers["cache-control"] = cacheTtlMs > 0 ? `public, max-age=${Math.floor(cacheTtlMs / 1000)}` : "no-store";
|
|
355
|
+
}
|
|
356
|
+
const durationMs = Date.now() - startedAt;
|
|
357
|
+
await safeInvokeHook("onRequestEnd", hooks.onRequestEnd, {
|
|
358
|
+
requestId,
|
|
359
|
+
queryKey: endpoint.key,
|
|
360
|
+
metadata: metadataWithAuth,
|
|
361
|
+
request,
|
|
362
|
+
auth: context.auth,
|
|
363
|
+
durationMs,
|
|
364
|
+
result,
|
|
365
|
+
});
|
|
366
|
+
return {
|
|
367
|
+
status: 200,
|
|
368
|
+
headers,
|
|
369
|
+
body: result,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
catch (error) {
|
|
373
|
+
await safeInvokeHook("onError", hooks.onError, {
|
|
374
|
+
requestId,
|
|
375
|
+
queryKey: endpoint.key,
|
|
376
|
+
metadata: context.metadata,
|
|
377
|
+
request,
|
|
378
|
+
auth: context.auth,
|
|
379
|
+
durationMs: Date.now() - startedAt,
|
|
380
|
+
error,
|
|
381
|
+
});
|
|
382
|
+
const message = error instanceof Error ? error.message : "Unexpected error";
|
|
383
|
+
return createErrorResponse(500, "INTERNAL_SERVER_ERROR", message);
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
export const defineServe = (config) => {
|
|
387
|
+
const basePath = config.basePath ?? "";
|
|
388
|
+
const router = new ServeRouter(basePath);
|
|
389
|
+
const globalMiddlewares = [
|
|
390
|
+
...(config.middlewares ?? []),
|
|
391
|
+
];
|
|
392
|
+
const authStrategies = ensureArray(config.auth);
|
|
393
|
+
const globalTenantConfig = config.tenant;
|
|
394
|
+
const contextFactory = config.context;
|
|
395
|
+
const hooks = (config.hooks ?? {});
|
|
396
|
+
const openapiConfig = {
|
|
397
|
+
enabled: config.openapi?.enabled ?? true,
|
|
398
|
+
path: config.openapi?.path ?? "/openapi.json",
|
|
399
|
+
};
|
|
400
|
+
const docsConfig = {
|
|
401
|
+
enabled: config.docs?.enabled ?? true,
|
|
402
|
+
path: config.docs?.path ?? "/docs",
|
|
403
|
+
};
|
|
404
|
+
const openapiPublicPath = applyBasePath(basePath, openapiConfig.path);
|
|
405
|
+
const configuredQueries = config.queries ?? {};
|
|
406
|
+
const queryEntries = {};
|
|
407
|
+
const registerQuery = (key, definition) => {
|
|
408
|
+
queryEntries[key] = createEndpoint(String(key), definition);
|
|
409
|
+
};
|
|
410
|
+
for (const key of Object.keys(configuredQueries)) {
|
|
411
|
+
registerQuery(key, configuredQueries[key]);
|
|
412
|
+
}
|
|
413
|
+
const handler = async (request) => {
|
|
414
|
+
const requestId = getRequestId(request);
|
|
415
|
+
const endpoint = router.match(request.method, request.path);
|
|
416
|
+
if (!endpoint) {
|
|
417
|
+
return createErrorResponse(404, "NOT_FOUND", `No endpoint registered for ${request.method} ${request.path}`);
|
|
418
|
+
}
|
|
419
|
+
return executeEndpoint({
|
|
420
|
+
endpoint,
|
|
421
|
+
request,
|
|
422
|
+
requestId,
|
|
423
|
+
authStrategies,
|
|
424
|
+
contextFactory,
|
|
425
|
+
globalMiddlewares,
|
|
426
|
+
globalTenantConfig,
|
|
427
|
+
hooks,
|
|
428
|
+
});
|
|
429
|
+
};
|
|
430
|
+
// Track route configuration for client config extraction
|
|
431
|
+
const routeConfig = {};
|
|
432
|
+
const builder = {
|
|
433
|
+
queries: queryEntries,
|
|
434
|
+
_routeConfig: routeConfig,
|
|
435
|
+
route: (path, endpoint, options) => {
|
|
436
|
+
if (!endpoint) {
|
|
437
|
+
throw new Error("Endpoint definition is required when registering a route");
|
|
438
|
+
}
|
|
439
|
+
const method = options?.method ?? endpoint.method;
|
|
440
|
+
// Find the query key for this endpoint
|
|
441
|
+
const queryKey = Object.entries(queryEntries).find(([_, e]) => e === endpoint)?.[0];
|
|
442
|
+
if (queryKey) {
|
|
443
|
+
routeConfig[queryKey] = { method };
|
|
444
|
+
}
|
|
445
|
+
const normalizedPath = normalizeRoutePath(path);
|
|
446
|
+
const fallbackRequiresAuth = endpoint.auth
|
|
447
|
+
? true
|
|
448
|
+
: authStrategies.length > 0
|
|
449
|
+
? true
|
|
450
|
+
: undefined;
|
|
451
|
+
const requiresAuth = options?.requiresAuth ?? endpoint.metadata.requiresAuth ?? fallbackRequiresAuth;
|
|
452
|
+
const visibility = options?.visibility ?? endpoint.metadata.visibility ?? "public";
|
|
453
|
+
const metadata = {
|
|
454
|
+
...endpoint.metadata,
|
|
455
|
+
path: normalizedPath,
|
|
456
|
+
method,
|
|
457
|
+
summary: options?.summary ?? endpoint.metadata.summary,
|
|
458
|
+
description: options?.description ?? endpoint.metadata.description,
|
|
459
|
+
tags: mergeTags(endpoint.metadata.tags, options?.tags),
|
|
460
|
+
requiresAuth,
|
|
461
|
+
visibility,
|
|
462
|
+
};
|
|
463
|
+
const middlewares = [...endpoint.middlewares, ...(options?.middlewares ?? [])];
|
|
464
|
+
const registeredEndpoint = {
|
|
465
|
+
...endpoint,
|
|
466
|
+
method,
|
|
467
|
+
metadata,
|
|
468
|
+
middlewares,
|
|
469
|
+
};
|
|
470
|
+
router.register(registeredEndpoint);
|
|
471
|
+
return builder;
|
|
472
|
+
},
|
|
473
|
+
use: (middleware) => {
|
|
474
|
+
globalMiddlewares.push(middleware);
|
|
475
|
+
return builder;
|
|
476
|
+
},
|
|
477
|
+
useAuth: (strategy) => {
|
|
478
|
+
authStrategies.push(strategy);
|
|
479
|
+
router.markRoutesRequireAuth();
|
|
480
|
+
return builder;
|
|
481
|
+
},
|
|
482
|
+
execute: async (key, options) => {
|
|
483
|
+
const endpoint = queryEntries[key];
|
|
484
|
+
if (!endpoint) {
|
|
485
|
+
throw new Error(`No query registered for key ${String(key)}`);
|
|
486
|
+
}
|
|
487
|
+
// Build a synthetic request for direct execution
|
|
488
|
+
const request = {
|
|
489
|
+
method: endpoint.method,
|
|
490
|
+
path: options?.request?.path ?? endpoint.metadata.path ?? `/__execute/${String(key)}`,
|
|
491
|
+
query: options?.request?.query ?? {},
|
|
492
|
+
headers: options?.request?.headers ?? {},
|
|
493
|
+
body: options?.input ?? options?.request?.body,
|
|
494
|
+
raw: options?.request?.raw,
|
|
495
|
+
};
|
|
496
|
+
const requestId = getRequestId(request);
|
|
497
|
+
// Execute the endpoint directly using the shared helper
|
|
498
|
+
const response = await executeEndpoint({
|
|
499
|
+
endpoint,
|
|
500
|
+
request,
|
|
501
|
+
requestId,
|
|
502
|
+
authStrategies,
|
|
503
|
+
contextFactory,
|
|
504
|
+
globalMiddlewares,
|
|
505
|
+
globalTenantConfig,
|
|
506
|
+
hooks,
|
|
507
|
+
additionalContext: options?.context,
|
|
508
|
+
});
|
|
509
|
+
// If the response indicates an error, throw it
|
|
510
|
+
if (response.status !== 200) {
|
|
511
|
+
const errorBody = response.body;
|
|
512
|
+
const error = new Error(errorBody.error.message);
|
|
513
|
+
error.type = errorBody.error.type;
|
|
514
|
+
if (errorBody.error.details) {
|
|
515
|
+
error.details = errorBody.error.details;
|
|
516
|
+
}
|
|
517
|
+
throw error;
|
|
518
|
+
}
|
|
519
|
+
// Return the successful response body
|
|
520
|
+
return response.body;
|
|
521
|
+
},
|
|
522
|
+
describe: () => {
|
|
523
|
+
const description = {
|
|
524
|
+
basePath: basePath || undefined,
|
|
525
|
+
queries: router.list().map(mapEndpointToToolkit),
|
|
526
|
+
};
|
|
527
|
+
return description;
|
|
528
|
+
},
|
|
529
|
+
handler,
|
|
530
|
+
start: async (options) => startNodeServer(handler, options),
|
|
531
|
+
};
|
|
532
|
+
if (openapiConfig.enabled) {
|
|
533
|
+
const openapiEndpoint = createOpenApiEndpoint(openapiConfig.path, () => router.list(), config.openapi);
|
|
534
|
+
router.register(openapiEndpoint);
|
|
535
|
+
}
|
|
536
|
+
if (docsConfig.enabled) {
|
|
537
|
+
const docsEndpoint = createDocsEndpoint(docsConfig.path, openapiPublicPath, config.docs);
|
|
538
|
+
router.register(docsEndpoint);
|
|
539
|
+
}
|
|
540
|
+
return builder;
|
|
541
|
+
};
|
|
542
|
+
const mapEndpointToToolkit = (endpoint) => {
|
|
543
|
+
// Use type assertion to avoid deep type instantiation issues with zodToJsonSchema
|
|
544
|
+
const inputSchema = endpoint.inputSchema
|
|
545
|
+
? zodToJsonSchema(endpoint.inputSchema, { target: "openApi3" })
|
|
546
|
+
: undefined;
|
|
547
|
+
const outputSchema = endpoint.outputSchema
|
|
548
|
+
? zodToJsonSchema(endpoint.outputSchema, { target: "openApi3" })
|
|
549
|
+
: undefined;
|
|
550
|
+
return {
|
|
551
|
+
key: endpoint.key,
|
|
552
|
+
path: endpoint.metadata.path,
|
|
553
|
+
method: endpoint.method,
|
|
554
|
+
summary: endpoint.metadata.summary,
|
|
555
|
+
description: endpoint.metadata.description,
|
|
556
|
+
tags: endpoint.metadata.tags,
|
|
557
|
+
visibility: endpoint.metadata.visibility,
|
|
558
|
+
requiresAuth: Boolean(endpoint.metadata.requiresAuth),
|
|
559
|
+
requiresTenant: endpoint.tenant ? (endpoint.tenant.required !== false) : undefined,
|
|
560
|
+
inputSchema,
|
|
561
|
+
outputSchema,
|
|
562
|
+
custom: endpoint.metadata.custom,
|
|
563
|
+
};
|
|
564
|
+
};
|
|
565
|
+
export const initServe = (options) => {
|
|
566
|
+
const { context, ...staticOptions } = options;
|
|
567
|
+
const procedure = createProcedureBuilder();
|
|
568
|
+
return {
|
|
569
|
+
procedure,
|
|
570
|
+
query: procedure,
|
|
571
|
+
queries: (definitions) => definitions,
|
|
572
|
+
define: (config) => {
|
|
573
|
+
return defineServe({
|
|
574
|
+
...staticOptions,
|
|
575
|
+
...config,
|
|
576
|
+
context: (context ?? {}),
|
|
577
|
+
});
|
|
578
|
+
},
|
|
579
|
+
};
|
|
580
|
+
};
|
package/dist/tenant.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utilities for multi-tenant query isolation
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Creates a tenant-scoped query builder wrapper that automatically
|
|
6
|
+
* adds WHERE clauses to filter by tenant.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* const api = defineServe({
|
|
11
|
+
* context: ({ auth }) => ({
|
|
12
|
+
* db: createTenantScope(myDb, {
|
|
13
|
+
* tenantId: auth?.tenantId,
|
|
14
|
+
* column: 'organization_id',
|
|
15
|
+
* }),
|
|
16
|
+
* }),
|
|
17
|
+
* });
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export declare function createTenantScope<TDb extends {
|
|
21
|
+
table: (name: string) => any;
|
|
22
|
+
}>(db: TDb, options: {
|
|
23
|
+
tenantId: string | null | undefined;
|
|
24
|
+
column: string;
|
|
25
|
+
}): TDb;
|
|
26
|
+
/**
|
|
27
|
+
* Runtime warning when tenant isolation might be misconfigured
|
|
28
|
+
*/
|
|
29
|
+
export declare function warnTenantMisconfiguration(options: {
|
|
30
|
+
queryKey: string;
|
|
31
|
+
hasTenantConfig: boolean;
|
|
32
|
+
hasTenantId: boolean;
|
|
33
|
+
mode?: string;
|
|
34
|
+
}): void;
|
|
35
|
+
//# sourceMappingURL=tenant.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tenant.d.ts","sourceRoot":"","sources":["../src/tenant.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,SAAS;IAAE,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,GAAG,CAAA;CAAE,EAC5E,EAAE,EAAE,GAAG,EACP,OAAO,EAAE;IACP,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;IACpC,MAAM,EAAE,MAAM,CAAC;CAChB,GACA,GAAG,CAoBL;AAED;;GAEG;AACH,wBAAgB,0BAA0B,CAAC,OAAO,EAAE;IAClD,QAAQ,EAAE,MAAM,CAAC;IACjB,eAAe,EAAE,OAAO,CAAC;IACzB,WAAW,EAAE,OAAO,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,QAYA"}
|
package/dist/tenant.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utilities for multi-tenant query isolation
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Creates a tenant-scoped query builder wrapper that automatically
|
|
6
|
+
* adds WHERE clauses to filter by tenant.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* const api = defineServe({
|
|
11
|
+
* context: ({ auth }) => ({
|
|
12
|
+
* db: createTenantScope(myDb, {
|
|
13
|
+
* tenantId: auth?.tenantId,
|
|
14
|
+
* column: 'organization_id',
|
|
15
|
+
* }),
|
|
16
|
+
* }),
|
|
17
|
+
* });
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export function createTenantScope(db, options) {
|
|
21
|
+
if (!options.tenantId) {
|
|
22
|
+
return db;
|
|
23
|
+
}
|
|
24
|
+
const originalTable = db.table.bind(db);
|
|
25
|
+
return {
|
|
26
|
+
...db,
|
|
27
|
+
table: (name) => {
|
|
28
|
+
const query = originalTable(name);
|
|
29
|
+
// Auto-inject tenant filter
|
|
30
|
+
if (query && typeof query.where === 'function') {
|
|
31
|
+
return query.where(options.column, '=', options.tenantId);
|
|
32
|
+
}
|
|
33
|
+
return query;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Runtime warning when tenant isolation might be misconfigured
|
|
39
|
+
*/
|
|
40
|
+
export function warnTenantMisconfiguration(options) {
|
|
41
|
+
if (!options.hasTenantConfig) {
|
|
42
|
+
console.warn(`[hypequery/serve] Query "${options.queryKey}" accesses user data but has no tenant configuration. ` +
|
|
43
|
+
`This may lead to data leaks. Add tenant config to defineServe or the query definition.`);
|
|
44
|
+
}
|
|
45
|
+
else if (options.hasTenantId && options.mode === 'manual') {
|
|
46
|
+
console.warn(`[hypequery/serve] Query "${options.queryKey}" uses manual tenant mode. ` +
|
|
47
|
+
`Ensure you manually filter queries by tenantId to prevent data leaks.`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const api: import("../types.js").ServeBuilder<import("../types.js").ServeEndpointMap<{
|
|
3
|
+
typedQuery: import("../types.js").ServeQueryConfig<z.ZodObject<{
|
|
4
|
+
plan: z.ZodOptional<z.ZodString>;
|
|
5
|
+
}, "strip", z.ZodTypeAny, {
|
|
6
|
+
plan?: string | undefined;
|
|
7
|
+
}, {
|
|
8
|
+
plan?: string | undefined;
|
|
9
|
+
}>, z.ZodTypeAny, {}, import("../types.js").AuthContext, {
|
|
10
|
+
plan: string;
|
|
11
|
+
}[]>;
|
|
12
|
+
}, {}, import("../types.js").AuthContext>, {}, import("../types.js").AuthContext>;
|
|
13
|
+
//# sourceMappingURL=builder.test-d.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"builder.test-d.d.ts","sourceRoot":"","sources":["../../src/type-tests/builder.test-d.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAUxB,eAAO,MAAM,GAAG;;;;;;;;;;iFAUd,CAAC"}
|