@terreno/api 0.3.1 → 0.4.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/dist/api.js +9 -8
- package/dist/betterAuthSetup.js +1 -1
- package/dist/configuration.test.d.ts +1 -0
- package/dist/configuration.test.js +699 -0
- package/dist/configurationApp.d.ts +91 -0
- package/dist/configurationApp.js +407 -0
- package/dist/configurationPlugin.d.ts +102 -0
- package/dist/configurationPlugin.js +285 -0
- package/dist/configurationPlugin.test.d.ts +1 -0
- package/dist/configurationPlugin.test.js +509 -0
- package/dist/example.js +1 -1
- package/dist/expressServer.js +5 -1
- package/dist/githubAuth.js +2 -2
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/openApiCompat.d.ts +23 -0
- package/dist/openApiCompat.js +198 -0
- package/dist/scriptRunner.d.ts +52 -0
- package/dist/scriptRunner.js +231 -0
- package/dist/secretProviders.d.ts +47 -0
- package/dist/secretProviders.js +214 -0
- package/dist/terrenoApp.d.ts +25 -0
- package/dist/terrenoApp.js +49 -2
- package/dist/tests.d.ts +27 -9
- package/dist/tests.js +10 -1
- package/package.json +13 -13
- package/src/api.ts +9 -8
- package/src/betterAuthSetup.ts +2 -2
- package/src/configuration.test.ts +398 -0
- package/src/configurationApp.ts +359 -0
- package/src/configurationPlugin.test.ts +299 -0
- package/src/configurationPlugin.ts +288 -0
- package/src/example.ts +1 -1
- package/src/expressServer.ts +6 -1
- package/src/githubAuth.ts +4 -4
- package/src/index.ts +5 -0
- package/src/openApiCompat.ts +147 -0
- package/src/permissions.ts +1 -1
- package/src/scriptRunner.ts +219 -0
- package/src/secretProviders.ts +109 -0
- package/src/terrenoApp.ts +44 -2
- package/src/tests.ts +12 -1
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import type {Document, Model, Schema} from "mongoose";
|
|
2
|
+
|
|
3
|
+
import {APIError} from "./errors";
|
|
4
|
+
import {logger} from "./logger";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Metadata for a secret field discovered by the configuration plugin.
|
|
8
|
+
*/
|
|
9
|
+
export interface SecretFieldMeta {
|
|
10
|
+
path: string;
|
|
11
|
+
secretProvider?: string;
|
|
12
|
+
secretName: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Interface for adapters that resolve secret values from external providers.
|
|
17
|
+
*/
|
|
18
|
+
export interface SecretProvider {
|
|
19
|
+
name: string;
|
|
20
|
+
getSecret(secretName: string): Promise<string | null>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Options passed to configurationPlugin.
|
|
25
|
+
*/
|
|
26
|
+
export interface ConfigurationPluginOptions {
|
|
27
|
+
/**
|
|
28
|
+
* Secret provider used when resolveSecrets() is called without an explicit provider.
|
|
29
|
+
* Typically set during app startup so the model can resolve secrets on demand.
|
|
30
|
+
*/
|
|
31
|
+
secretProvider?: SecretProvider;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Path type utilities
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* All dot-notation paths for a type T.
|
|
40
|
+
* @example Paths<{a: {b: string}; c: number}> = "a" | "a.b" | "c"
|
|
41
|
+
*/
|
|
42
|
+
export type Paths<T extends object> = {
|
|
43
|
+
[K in keyof T & string]: T[K] extends object ? K | `${K}.${Paths<T[K]>}` : K;
|
|
44
|
+
}[keyof T & string];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The value type at a dot-notation path P within type T.
|
|
48
|
+
* @example PathValue<{a: {b: string}}, "a.b"> = string
|
|
49
|
+
*/
|
|
50
|
+
export type PathValue<T, P extends string> = P extends `${infer K}.${infer Rest}`
|
|
51
|
+
? K extends keyof T
|
|
52
|
+
? PathValue<NonNullable<T[K]>, Rest>
|
|
53
|
+
: never
|
|
54
|
+
: P extends keyof T
|
|
55
|
+
? T[P]
|
|
56
|
+
: never;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Deeply partial version of T, for use in updateConfig.
|
|
60
|
+
*/
|
|
61
|
+
export type DeepPartial<T> = {
|
|
62
|
+
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Statics interface
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Static methods added by configurationPlugin to the Mongoose model.
|
|
71
|
+
*/
|
|
72
|
+
export interface ConfigurationStatics<T extends object> {
|
|
73
|
+
/** Get the full singleton configuration document. */
|
|
74
|
+
getConfig(): Promise<T & Document>;
|
|
75
|
+
/** Get a specific value by dot-notation key. */
|
|
76
|
+
getConfig<P extends Paths<T>>(key: P): Promise<PathValue<T, P>>;
|
|
77
|
+
/** Update the singleton configuration document (deep merge). */
|
|
78
|
+
updateConfig(updates: DeepPartial<T>): Promise<T & Document>;
|
|
79
|
+
/** Get secret field metadata discovered from the schema. */
|
|
80
|
+
getSecretFields(): SecretFieldMeta[];
|
|
81
|
+
/**
|
|
82
|
+
* Resolve all secret field values from a provider.
|
|
83
|
+
* Uses the provider passed here, or falls back to the one configured in the plugin options.
|
|
84
|
+
* Returns a map of path -> value.
|
|
85
|
+
*/
|
|
86
|
+
resolveSecrets(provider?: SecretProvider): Promise<Map<string, string>>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Convenience type for a Mongoose model with configurationPlugin applied.
|
|
91
|
+
*
|
|
92
|
+
* Use this when declaring your configuration model to get full type safety:
|
|
93
|
+
* ```typescript
|
|
94
|
+
* export const AppConfig = mongoose.model<AppConfigDocument, ConfigurationModel<AppConfigDocument>>(
|
|
95
|
+
* "AppConfig",
|
|
96
|
+
* appConfigSchema,
|
|
97
|
+
* );
|
|
98
|
+
* // Then call:
|
|
99
|
+
* const name = await AppConfig.getConfig("general.appName"); // typed as string
|
|
100
|
+
* const full = await AppConfig.getConfig(); // typed as AppConfigDocument
|
|
101
|
+
* ```
|
|
102
|
+
*/
|
|
103
|
+
export type ConfigurationModel<T extends object> = Model<T> & ConfigurationStatics<T>;
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Plugin
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Mongoose schema plugin that adds singleton configuration behavior.
|
|
111
|
+
*
|
|
112
|
+
* Adds:
|
|
113
|
+
* - Pre-save hook enforcing exactly one document
|
|
114
|
+
* - `getConfig()` static: fetches or creates the singleton (full doc or keyed value)
|
|
115
|
+
* - `updateConfig(updates)` static: patches the singleton
|
|
116
|
+
* - `getSecretFields()` static: returns metadata for fields with `secret: true`
|
|
117
|
+
* - `resolveSecrets(provider?)` static: fetches secret values, using the plugin provider by default
|
|
118
|
+
*
|
|
119
|
+
* Mark fields as secrets using schema path options:
|
|
120
|
+
* ```typescript
|
|
121
|
+
* const configSchema = new Schema({
|
|
122
|
+
* apiKey: {
|
|
123
|
+
* type: String,
|
|
124
|
+
* description: "Third-party API key",
|
|
125
|
+
* secret: true,
|
|
126
|
+
* secretName: "my-api-key",
|
|
127
|
+
* },
|
|
128
|
+
* });
|
|
129
|
+
* configSchema.plugin(configurationPlugin, {secretProvider: new EnvSecretProvider()});
|
|
130
|
+
* ```
|
|
131
|
+
*/
|
|
132
|
+
export const configurationPlugin = (schema: Schema, options?: ConfigurationPluginOptions): void => {
|
|
133
|
+
const pluginOptions = options ?? {};
|
|
134
|
+
|
|
135
|
+
// Add a sentinel field with a unique index to enforce singleton at the DB level.
|
|
136
|
+
// All config documents get _singleton: "config", and the unique index prevents duplicates.
|
|
137
|
+
schema.add({
|
|
138
|
+
_singleton: {default: "config", immutable: true, select: false, type: String},
|
|
139
|
+
});
|
|
140
|
+
schema.index({_singleton: 1}, {unique: true});
|
|
141
|
+
|
|
142
|
+
// Enforce singleton: only one document allowed (application-level guard)
|
|
143
|
+
schema.pre("save", async function () {
|
|
144
|
+
if (this.isNew) {
|
|
145
|
+
// Intentional unfiltered findOne — checking if any singleton document exists
|
|
146
|
+
const existing = await (this.constructor as Model<unknown>).findOne({});
|
|
147
|
+
if (existing) {
|
|
148
|
+
throw new APIError({
|
|
149
|
+
status: 409,
|
|
150
|
+
title: "Only one configuration document is allowed. Use updateConfig() instead.",
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Prevent hard deletion of the singleton (soft deletes via isDeletedPlugin still work)
|
|
157
|
+
const createHardDeleteError = (): APIError =>
|
|
158
|
+
new APIError({
|
|
159
|
+
status: 400,
|
|
160
|
+
title:
|
|
161
|
+
"Cannot hard-delete the configuration document. Use updateConfig() or soft delete instead.",
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
schema.pre("deleteOne", {document: true, query: true}, () => {
|
|
165
|
+
throw createHardDeleteError();
|
|
166
|
+
});
|
|
167
|
+
schema.pre("deleteMany", () => {
|
|
168
|
+
throw createHardDeleteError();
|
|
169
|
+
});
|
|
170
|
+
schema.pre("findOneAndDelete", () => {
|
|
171
|
+
throw createHardDeleteError();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Static: get the singleton configuration document or a value at a path (race-safe via upsert)
|
|
175
|
+
schema.statics.getConfig = async function (key?: string): Promise<unknown> {
|
|
176
|
+
let config = await this.findOne({});
|
|
177
|
+
if (!config) {
|
|
178
|
+
try {
|
|
179
|
+
// Use `new` + `save` instead of `create({})` so Mongoose initializes
|
|
180
|
+
// nested subdocument defaults (create({}) skips them).
|
|
181
|
+
config = new this();
|
|
182
|
+
await config.save();
|
|
183
|
+
} catch (err: unknown) {
|
|
184
|
+
// If another process created the document between findOne and create,
|
|
185
|
+
// the pre-save hook will throw a 409. Just fetch the existing one.
|
|
186
|
+
if ((err as {status?: number})?.status === 409) {
|
|
187
|
+
config = await this.findOne({});
|
|
188
|
+
} else {
|
|
189
|
+
throw err;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (key === undefined) {
|
|
195
|
+
return config;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Resolve dot-notation key into the document
|
|
199
|
+
const parts = key.split(".");
|
|
200
|
+
let value: unknown = config.toObject();
|
|
201
|
+
for (const part of parts) {
|
|
202
|
+
if (value == null || typeof value !== "object") {
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
value = (value as Record<string, unknown>)[part];
|
|
206
|
+
}
|
|
207
|
+
return value;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
// Static: update the singleton configuration document (race-safe)
|
|
211
|
+
schema.statics.updateConfig = async function (
|
|
212
|
+
updates: Record<string, unknown>
|
|
213
|
+
): Promise<unknown> {
|
|
214
|
+
const config = await (this as ConfigurationModel<Record<string, unknown>>).getConfig();
|
|
215
|
+
Object.assign(config, updates);
|
|
216
|
+
await (config as Document).save();
|
|
217
|
+
return config;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
// Static: discover secret fields from schema options
|
|
221
|
+
schema.statics.getSecretFields = function (): SecretFieldMeta[] {
|
|
222
|
+
const secrets: SecretFieldMeta[] = [];
|
|
223
|
+
const discoverSecrets = (s: Schema, prefix: string) => {
|
|
224
|
+
s.eachPath((pathName, schemaType) => {
|
|
225
|
+
const opts = schemaType.options as Record<string, unknown>;
|
|
226
|
+
if (opts?.secret === true) {
|
|
227
|
+
secrets.push({
|
|
228
|
+
path: prefix ? `${prefix}.${pathName}` : pathName,
|
|
229
|
+
secretName: (opts.secretName as string) ?? pathName,
|
|
230
|
+
secretProvider: opts.secretProvider as string | undefined,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
// Recurse into subschemas
|
|
234
|
+
if ((schemaType as {schema?: Schema}).schema) {
|
|
235
|
+
discoverSecrets(
|
|
236
|
+
(schemaType as {schema: Schema}).schema,
|
|
237
|
+
prefix ? `${prefix}.${pathName}` : pathName
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
};
|
|
242
|
+
discoverSecrets(this.schema, "");
|
|
243
|
+
return secrets;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
// Static: resolve secret values from a provider
|
|
247
|
+
schema.statics.resolveSecrets = async function (
|
|
248
|
+
provider?: SecretProvider
|
|
249
|
+
): Promise<Map<string, string>> {
|
|
250
|
+
const resolvedProvider = provider ?? pluginOptions.secretProvider;
|
|
251
|
+
if (!resolvedProvider) {
|
|
252
|
+
logger.warn(
|
|
253
|
+
"resolveSecrets called with no provider. Pass a SecretProvider to resolveSecrets() or configurationPlugin options."
|
|
254
|
+
);
|
|
255
|
+
return new Map();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const secrets = (this as ConfigurationModel<Record<string, unknown>>).getSecretFields();
|
|
259
|
+
const resolved = new Map<string, string>();
|
|
260
|
+
|
|
261
|
+
const results = await Promise.allSettled(
|
|
262
|
+
secrets.map(async (meta: SecretFieldMeta) => {
|
|
263
|
+
const value = await resolvedProvider.getSecret(meta.secretName);
|
|
264
|
+
if (value !== null) {
|
|
265
|
+
resolved.set(meta.path, value);
|
|
266
|
+
}
|
|
267
|
+
})
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
let failCount = 0;
|
|
271
|
+
for (const result of results) {
|
|
272
|
+
if (result.status === "rejected") {
|
|
273
|
+
failCount++;
|
|
274
|
+
logger.error(`Failed to resolve secret: ${result.reason}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (failCount > 0) {
|
|
279
|
+
logger.warn(`${failCount}/${secrets.length} secrets failed to resolve`);
|
|
280
|
+
} else if (secrets.length > 0) {
|
|
281
|
+
logger.info(
|
|
282
|
+
`Resolved ${resolved.size}/${secrets.length} secrets from ${resolvedProvider.name}`
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return resolved;
|
|
287
|
+
};
|
|
288
|
+
};
|
package/src/example.ts
CHANGED
|
@@ -54,7 +54,7 @@ const FoodModel = model<Food>("Food", schema);
|
|
|
54
54
|
function getBaseServer() {
|
|
55
55
|
const app = express();
|
|
56
56
|
|
|
57
|
-
app.
|
|
57
|
+
app.use((req, res, next) => {
|
|
58
58
|
res.header("Access-Control-Allow-Origin", "*");
|
|
59
59
|
res.header("Access-Control-Allow-Headers", "*");
|
|
60
60
|
// intercepts OPTIONS method
|
package/src/expressServer.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {apiErrorMiddleware, apiUnauthorizedMiddleware} from "./errors";
|
|
|
15
15
|
import {addGitHubAuthRoutes, type GitHubAuthOptions, setupGitHubAuth} from "./githubAuth";
|
|
16
16
|
import {type LoggingOptions, logger, setupLogging} from "./logger";
|
|
17
17
|
import {sendToSlack} from "./notifiers";
|
|
18
|
+
import {openApiCompatMiddleware, patchAppUse} from "./openApiCompat";
|
|
18
19
|
import {openApiEtagMiddleware} from "./openApiEtag";
|
|
19
20
|
|
|
20
21
|
const SLOW_READ_MAX = 200;
|
|
@@ -180,6 +181,9 @@ function initializeRoutes(
|
|
|
180
181
|
): express.Application {
|
|
181
182
|
const app = express();
|
|
182
183
|
|
|
184
|
+
// Record mount paths on layers for Express 5 → OpenAPI compat
|
|
185
|
+
patchAppUse(app);
|
|
186
|
+
|
|
183
187
|
// TODO: Log a warning when we hit the array limit.
|
|
184
188
|
app.set("query parser", (str: string) => qs.parse(str, {arrayLimit: options.arrayLimit ?? 200}));
|
|
185
189
|
|
|
@@ -210,7 +214,7 @@ function initializeRoutes(
|
|
|
210
214
|
});
|
|
211
215
|
|
|
212
216
|
// Add Sentry scopes for session, transaction, and userId if any are set
|
|
213
|
-
app.
|
|
217
|
+
app.use((req: any, _res: any, next: any) => {
|
|
214
218
|
const transactionId = req.header("X-Transaction-ID");
|
|
215
219
|
const sessionId = req.header("X-Session-ID");
|
|
216
220
|
if (transactionId) {
|
|
@@ -226,6 +230,7 @@ function initializeRoutes(
|
|
|
226
230
|
});
|
|
227
231
|
|
|
228
232
|
// Add ETag middleware for OpenAPI JSON endpoint before the openapi middleware
|
|
233
|
+
app.use(openApiCompatMiddleware);
|
|
229
234
|
app.use(openApiEtagMiddleware);
|
|
230
235
|
|
|
231
236
|
const oapi = openapi({
|
package/src/githubAuth.ts
CHANGED
|
@@ -79,7 +79,7 @@ export function setupGitHubAuth(
|
|
|
79
79
|
|
|
80
80
|
passport.use(
|
|
81
81
|
"github",
|
|
82
|
-
new GitHubStrategy(
|
|
82
|
+
new (GitHubStrategy as any)(
|
|
83
83
|
{
|
|
84
84
|
callbackURL: githubOptions.callbackURL,
|
|
85
85
|
clientID: githubOptions.clientId,
|
|
@@ -87,8 +87,8 @@ export function setupGitHubAuth(
|
|
|
87
87
|
passReqToCallback: true,
|
|
88
88
|
scope,
|
|
89
89
|
},
|
|
90
|
-
async (
|
|
91
|
-
req:
|
|
90
|
+
(async (
|
|
91
|
+
req: any,
|
|
92
92
|
accessToken: string,
|
|
93
93
|
refreshToken: string,
|
|
94
94
|
profile: Profile,
|
|
@@ -192,7 +192,7 @@ export function setupGitHubAuth(
|
|
|
192
192
|
logger.error(`GitHub auth error: ${error}`);
|
|
193
193
|
return done(error);
|
|
194
194
|
}
|
|
195
|
-
}
|
|
195
|
+
}) as any
|
|
196
196
|
) as passport.Strategy
|
|
197
197
|
);
|
|
198
198
|
}
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,8 @@ export * from "./auth";
|
|
|
3
3
|
export * from "./betterAuth";
|
|
4
4
|
export * from "./betterAuthApp";
|
|
5
5
|
export * from "./betterAuthSetup";
|
|
6
|
+
export * from "./configurationApp";
|
|
7
|
+
export * from "./configurationPlugin";
|
|
6
8
|
export * from "./errors";
|
|
7
9
|
export * from "./expressServer";
|
|
8
10
|
export * from "./githubAuth";
|
|
@@ -10,11 +12,14 @@ export * from "./logger";
|
|
|
10
12
|
export * from "./middleware";
|
|
11
13
|
export * from "./notifiers";
|
|
12
14
|
export * from "./openApiBuilder";
|
|
15
|
+
export * from "./openApiCompat";
|
|
13
16
|
export * from "./openApiEtag";
|
|
14
17
|
export * from "./openApiValidator";
|
|
15
18
|
export * from "./permissions";
|
|
16
19
|
export * from "./plugins";
|
|
17
20
|
export * from "./populate";
|
|
21
|
+
export * from "./scriptRunner";
|
|
22
|
+
export * from "./secretProviders";
|
|
18
23
|
export * from "./terrenoApp";
|
|
19
24
|
export * from "./terrenoPlugin";
|
|
20
25
|
export * from "./transformers";
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Patches the Express router stack to add `.regexp` on layers for
|
|
3
|
+
* compatibility with @wesleytodd/openapi, which expects Express 4-style
|
|
4
|
+
* layers with `.regexp.fast_slash`.
|
|
5
|
+
*
|
|
6
|
+
* In Express 5 (router@2.x), layers use `.slash` (boolean) and `.matchers`
|
|
7
|
+
* (array of functions) instead of `.regexp`.
|
|
8
|
+
*
|
|
9
|
+
* @see https://github.com/wesleytodd/express-openapi/issues/70
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const MOUNT_PATH_KEY = "__openApiMountPath";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Extract Express 4-style keys from a path string.
|
|
16
|
+
* Parses `:paramName` and `*paramName` segments into `{name, optional}` objects
|
|
17
|
+
* that @wesleytodd/openapi expects.
|
|
18
|
+
*/
|
|
19
|
+
const extractKeysFromPath = (path: string): Array<{name: string; optional: boolean}> => {
|
|
20
|
+
const keys: Array<{name: string; optional: boolean}> = [];
|
|
21
|
+
const paramRegex = /[:*](\w+)\??/g;
|
|
22
|
+
let match: RegExpExecArray | null;
|
|
23
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: standard regex exec loop
|
|
24
|
+
while ((match = paramRegex.exec(path)) !== null) {
|
|
25
|
+
keys.push({name: match[1], optional: match[0].endsWith("?")});
|
|
26
|
+
}
|
|
27
|
+
return keys;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build an Express 4-style regexp from a path string for the openapi parser.
|
|
32
|
+
*
|
|
33
|
+
* For paths without params (e.g., `/food`), produces a simple escaped regexp
|
|
34
|
+
* that the `split()` function in @wesleytodd/openapi can parse directly.
|
|
35
|
+
*
|
|
36
|
+
* For paths with `:params` (e.g., `/food/:id`), replaces each param with the
|
|
37
|
+
* Express 4-style capture group `(?:([^\/]+?))` so that `processComplexMatch()`
|
|
38
|
+
* in the openapi library can map them to `{paramName}` using `layer.keys`.
|
|
39
|
+
*/
|
|
40
|
+
const buildRegexpForPath = (pathStr: string, isMount: boolean): RegExp => {
|
|
41
|
+
// Replace :param segments with Express 4-style capture groups, then escape the rest
|
|
42
|
+
const parts = pathStr.split("/").map((segment) => {
|
|
43
|
+
if (segment.startsWith(":")) {
|
|
44
|
+
return "(?:([^\\/]+?))";
|
|
45
|
+
}
|
|
46
|
+
return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
47
|
+
});
|
|
48
|
+
const pattern = parts.join("\\/");
|
|
49
|
+
if (isMount) {
|
|
50
|
+
return new RegExp(`^${pattern}\\/?(?=\\/|$)`);
|
|
51
|
+
}
|
|
52
|
+
return new RegExp(`^${pattern}\\/?$`);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const patchRouterStack = (stack: any[]): void => {
|
|
56
|
+
for (const layer of stack) {
|
|
57
|
+
if (layer.regexp !== undefined) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Determine the path string for this layer
|
|
62
|
+
let pathStr: string | undefined;
|
|
63
|
+
const isMount = layer.name === "router" || !!layer.handle?.stack;
|
|
64
|
+
|
|
65
|
+
if (layer.slash) {
|
|
66
|
+
// Express 5 layers use .slash instead of .regexp.fast_slash
|
|
67
|
+
layer.regexp = {fast_slash: true};
|
|
68
|
+
} else if (layer[MOUNT_PATH_KEY]) {
|
|
69
|
+
pathStr = layer[MOUNT_PATH_KEY] as string;
|
|
70
|
+
layer.regexp = buildRegexpForPath(pathStr, isMount);
|
|
71
|
+
} else if (layer.path && typeof layer.path === "string") {
|
|
72
|
+
pathStr = layer.path as string;
|
|
73
|
+
layer.regexp = buildRegexpForPath(pathStr, false);
|
|
74
|
+
} else if (layer.route?.path && typeof layer.route.path === "string") {
|
|
75
|
+
pathStr = layer.route.path as string;
|
|
76
|
+
layer.regexp = buildRegexpForPath(pathStr, false);
|
|
77
|
+
} else {
|
|
78
|
+
layer.regexp = /^\/?$/;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Populate keys in Express 4 format: [{name, optional}]
|
|
82
|
+
// @wesleytodd/openapi reads layer.keys[i].name for path parameters
|
|
83
|
+
if (!layer.keys || (Array.isArray(layer.keys) && layer.keys.length === 0)) {
|
|
84
|
+
if (pathStr) {
|
|
85
|
+
layer.keys = extractKeysFromPath(pathStr);
|
|
86
|
+
} else {
|
|
87
|
+
layer.keys = [];
|
|
88
|
+
}
|
|
89
|
+
} else if (Array.isArray(layer.keys) && typeof layer.keys[0] === "string") {
|
|
90
|
+
// Express 5 stores keys as plain strings after match() — convert to objects
|
|
91
|
+
layer.keys = layer.keys.map((k: string) => ({name: k, optional: false}));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Recursively patch nested stacks
|
|
95
|
+
if (layer.handle?.stack) {
|
|
96
|
+
patchRouterStack(layer.handle.stack);
|
|
97
|
+
}
|
|
98
|
+
if (layer.route?.stack) {
|
|
99
|
+
patchRouterStack(layer.route.stack);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Wraps an Express app's `use` method to record the mount path on each
|
|
106
|
+
* layer added to the router stack. This runs at setup time so that
|
|
107
|
+
* `patchRouterStack` can read the original path later.
|
|
108
|
+
*
|
|
109
|
+
* Must be called before any routes are registered.
|
|
110
|
+
*/
|
|
111
|
+
export const patchAppUse = (app: any): void => {
|
|
112
|
+
const originalUse = app.use.bind(app);
|
|
113
|
+
app.use = function patchedUse(...args: any[]) {
|
|
114
|
+
// Track stack length before the call
|
|
115
|
+
const router = app._router || app.router;
|
|
116
|
+
const stackBefore = router?.stack?.length ?? 0;
|
|
117
|
+
|
|
118
|
+
const result = originalUse(...args);
|
|
119
|
+
|
|
120
|
+
// After use(), check if new layers were added and annotate them
|
|
121
|
+
const routerAfter = app._router || app.router;
|
|
122
|
+
if (routerAfter?.stack) {
|
|
123
|
+
const stackAfter = routerAfter.stack.length;
|
|
124
|
+
// The first arg is the mount path if it's a string
|
|
125
|
+
const mountPath = typeof args[0] === "string" ? args[0] : undefined;
|
|
126
|
+
if (mountPath && mountPath !== "/") {
|
|
127
|
+
for (let i = stackBefore; i < stackAfter; i++) {
|
|
128
|
+
routerAfter.stack[i][MOUNT_PATH_KEY] = mountPath.replace(/\/+$/, "");
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return result;
|
|
134
|
+
};
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Express middleware that patches the router stack before OpenAPI doc
|
|
139
|
+
* generation. Must be mounted before the openapi middleware.
|
|
140
|
+
*/
|
|
141
|
+
export const openApiCompatMiddleware = (req: any, _res: any, next: () => void): void => {
|
|
142
|
+
const router = req.app._router || req.app.router;
|
|
143
|
+
if (router?.stack) {
|
|
144
|
+
patchRouterStack(router.stack);
|
|
145
|
+
}
|
|
146
|
+
next();
|
|
147
|
+
};
|
package/src/permissions.ts
CHANGED
|
@@ -160,7 +160,7 @@ export function permissionMiddleware<T>(
|
|
|
160
160
|
if (!data) {
|
|
161
161
|
// Check if document exists but is hidden. Completely skip plugins.
|
|
162
162
|
const hiddenDoc = await model.collection.findOne({
|
|
163
|
-
_id: new mongoose.Types.ObjectId(req.params.id),
|
|
163
|
+
_id: new mongoose.Types.ObjectId(req.params.id as string),
|
|
164
164
|
});
|
|
165
165
|
|
|
166
166
|
if (!hiddenDoc) {
|