@terreno/api 0.3.1 → 0.4.2

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.
Files changed (42) hide show
  1. package/dist/api.js +9 -8
  2. package/dist/betterAuthSetup.js +1 -1
  3. package/dist/configuration.test.d.ts +1 -0
  4. package/dist/configuration.test.js +699 -0
  5. package/dist/configurationApp.d.ts +91 -0
  6. package/dist/configurationApp.js +407 -0
  7. package/dist/configurationPlugin.d.ts +102 -0
  8. package/dist/configurationPlugin.js +285 -0
  9. package/dist/configurationPlugin.test.d.ts +1 -0
  10. package/dist/configurationPlugin.test.js +509 -0
  11. package/dist/example.js +1 -1
  12. package/dist/expressServer.js +5 -1
  13. package/dist/githubAuth.js +2 -2
  14. package/dist/index.d.ts +5 -0
  15. package/dist/index.js +5 -0
  16. package/dist/openApiCompat.d.ts +23 -0
  17. package/dist/openApiCompat.js +198 -0
  18. package/dist/scriptRunner.d.ts +52 -0
  19. package/dist/scriptRunner.js +231 -0
  20. package/dist/secretProviders.d.ts +47 -0
  21. package/dist/secretProviders.js +214 -0
  22. package/dist/terrenoApp.d.ts +25 -0
  23. package/dist/terrenoApp.js +49 -2
  24. package/dist/tests.d.ts +27 -9
  25. package/dist/tests.js +10 -1
  26. package/package.json +13 -13
  27. package/src/api.ts +9 -8
  28. package/src/betterAuthSetup.ts +2 -2
  29. package/src/configuration.test.ts +398 -0
  30. package/src/configurationApp.ts +359 -0
  31. package/src/configurationPlugin.test.ts +299 -0
  32. package/src/configurationPlugin.ts +288 -0
  33. package/src/example.ts +1 -1
  34. package/src/expressServer.ts +6 -1
  35. package/src/githubAuth.ts +4 -4
  36. package/src/index.ts +5 -0
  37. package/src/openApiCompat.ts +147 -0
  38. package/src/permissions.ts +1 -1
  39. package/src/scriptRunner.ts +219 -0
  40. package/src/secretProviders.ts +109 -0
  41. package/src/terrenoApp.ts +44 -2
  42. package/src/tests.ts +12 -1
@@ -0,0 +1,219 @@
1
+ import {DateTime} from "luxon";
2
+ import mongoose, {type Document, type Model, Schema} from "mongoose";
3
+
4
+ import {createdUpdatedPlugin, findExactlyOne, findOneOrNone, isDeletedPlugin} from "./plugins";
5
+
6
+ // --- Script Runner Types & BackgroundTask Model ---
7
+
8
+ export interface ScriptResult {
9
+ success: boolean;
10
+ results: string[];
11
+ }
12
+
13
+ export interface ScriptContext {
14
+ /** Check if the task has been cancelled. Throws TaskCancelledError if so. */
15
+ checkCancellation: () => Promise<void>;
16
+ /** Add a log entry to the task. */
17
+ addLog: (level: "info" | "warn" | "error", message: string) => Promise<void>;
18
+ /** Update progress on the task. */
19
+ updateProgress: (percentage: number, stage?: string, message?: string) => Promise<void>;
20
+ }
21
+
22
+ export type ScriptRunner = (wetRun: boolean, ctx?: ScriptContext) => Promise<ScriptResult>;
23
+
24
+ export class TaskCancelledError extends Error {
25
+ constructor(taskId: string) {
26
+ super(`Task ${taskId} was cancelled`);
27
+ this.name = "TaskCancelledError";
28
+ }
29
+ }
30
+
31
+ // --- BackgroundTask Model ---
32
+
33
+ interface BackgroundTaskProgress {
34
+ percentage: number;
35
+ stage?: string;
36
+ message?: string;
37
+ }
38
+
39
+ interface BackgroundTaskLog {
40
+ timestamp: Date;
41
+ level: "info" | "warn" | "error";
42
+ message: string;
43
+ }
44
+
45
+ export type BackgroundTaskMethods = {
46
+ addLog: (
47
+ this: BackgroundTaskDocument,
48
+ level: "info" | "warn" | "error",
49
+ message: string
50
+ ) => Promise<void>;
51
+ updateProgress: (
52
+ this: BackgroundTaskDocument,
53
+ percentage: number,
54
+ stage?: string,
55
+ message?: string
56
+ ) => Promise<void>;
57
+ };
58
+
59
+ export type BackgroundTaskDocument = Document &
60
+ BackgroundTaskMethods & {
61
+ taskType: string;
62
+ status: "pending" | "running" | "completed" | "failed" | "cancelled";
63
+ progress?: BackgroundTaskProgress;
64
+ createdBy?: mongoose.Types.ObjectId;
65
+ isDryRun: boolean;
66
+ result?: string[];
67
+ error?: string;
68
+ logs: BackgroundTaskLog[];
69
+ startedAt?: Date;
70
+ completedAt?: Date;
71
+ created: Date;
72
+ updated: Date;
73
+ deleted: boolean;
74
+ };
75
+
76
+ export type BackgroundTaskStatics = {
77
+ checkCancellation: (taskId: string) => Promise<void>;
78
+ };
79
+
80
+ export type BackgroundTaskModel = Model<
81
+ BackgroundTaskDocument,
82
+ Record<string, never>,
83
+ BackgroundTaskMethods
84
+ > &
85
+ BackgroundTaskStatics;
86
+
87
+ const progressSchema = new Schema(
88
+ {
89
+ message: {description: "Human-readable progress message", type: String},
90
+ percentage: {description: "Progress percentage from 0 to 100", max: 100, min: 0, type: Number},
91
+ stage: {description: "Current stage of the task", type: String},
92
+ },
93
+ {_id: false}
94
+ );
95
+
96
+ const logSchema = new Schema(
97
+ {
98
+ level: {
99
+ description: "Log level",
100
+ enum: ["info", "warn", "error"],
101
+ required: true,
102
+ type: String,
103
+ },
104
+ message: {description: "Log message", required: true, type: String},
105
+ timestamp: {description: "When this log entry was created", required: true, type: Date},
106
+ },
107
+ {_id: false}
108
+ );
109
+
110
+ const backgroundTaskSchema = new Schema<
111
+ BackgroundTaskDocument,
112
+ BackgroundTaskModel,
113
+ BackgroundTaskMethods
114
+ >(
115
+ {
116
+ completedAt: {
117
+ description: "When the task completed (success or failure)",
118
+ type: Date,
119
+ },
120
+ createdBy: {
121
+ description: "The user who created this task",
122
+ ref: "User",
123
+ type: mongoose.Schema.Types.ObjectId,
124
+ },
125
+ error: {
126
+ description: "Error message if the task failed",
127
+ type: String,
128
+ },
129
+ isDryRun: {
130
+ default: false,
131
+ description: "Whether this is a dry run that does not make real changes",
132
+ type: Boolean,
133
+ },
134
+ logs: {
135
+ default: [],
136
+ description: "Log entries for the task execution",
137
+ type: [logSchema],
138
+ },
139
+ progress: {
140
+ description: "Progress information for the task",
141
+ type: progressSchema,
142
+ },
143
+ result: {
144
+ description: "Result strings when the task completes",
145
+ type: [String],
146
+ },
147
+ startedAt: {
148
+ description: "When the task started executing",
149
+ type: Date,
150
+ },
151
+ status: {
152
+ default: "pending",
153
+ description: "Current status of the task",
154
+ enum: ["pending", "running", "completed", "failed", "cancelled"],
155
+ type: String,
156
+ },
157
+ taskType: {
158
+ description: "The type or name of the background task",
159
+ required: true,
160
+ type: String,
161
+ },
162
+ },
163
+ {strict: "throw", toJSON: {virtuals: true}, toObject: {virtuals: true}}
164
+ );
165
+
166
+ backgroundTaskSchema.methods = {
167
+ async addLog(
168
+ this: BackgroundTaskDocument,
169
+ level: "info" | "warn" | "error",
170
+ message: string
171
+ ): Promise<void> {
172
+ if (!this.logs) {
173
+ this.logs = [];
174
+ }
175
+ this.logs.push({
176
+ level,
177
+ message,
178
+ timestamp: DateTime.now().toJSDate(),
179
+ });
180
+ await this.save();
181
+ },
182
+
183
+ async updateProgress(
184
+ this: BackgroundTaskDocument,
185
+ percentage: number,
186
+ stage?: string,
187
+ message?: string
188
+ ): Promise<void> {
189
+ this.progress = {
190
+ message: message ?? this.progress?.message,
191
+ percentage,
192
+ stage: stage ?? this.progress?.stage,
193
+ };
194
+ await this.save();
195
+ },
196
+ };
197
+
198
+ backgroundTaskSchema.statics = {
199
+ async checkCancellation(this: BackgroundTaskModel, taskId: string): Promise<void> {
200
+ const task = await this.findById(taskId).select("status").lean();
201
+ if (task?.status === "cancelled") {
202
+ throw new TaskCancelledError(taskId);
203
+ }
204
+ },
205
+ };
206
+
207
+ backgroundTaskSchema.index({createdBy: 1, status: 1});
208
+ backgroundTaskSchema.index({status: 1});
209
+ backgroundTaskSchema.index({status: 1, taskType: 1});
210
+
211
+ backgroundTaskSchema.plugin(createdUpdatedPlugin);
212
+ backgroundTaskSchema.plugin(isDeletedPlugin);
213
+ backgroundTaskSchema.plugin(findOneOrNone);
214
+ backgroundTaskSchema.plugin(findExactlyOne);
215
+
216
+ export const BackgroundTask = mongoose.model<BackgroundTaskDocument, BackgroundTaskModel>(
217
+ "BackgroundTask",
218
+ backgroundTaskSchema
219
+ );
@@ -0,0 +1,109 @@
1
+ import type {SecretProvider} from "./configurationPlugin";
2
+ import {logger} from "./logger";
3
+
4
+ /**
5
+ * Secret provider that reads secrets from environment variables.
6
+ * Useful for local development and testing.
7
+ *
8
+ * Maps secret names to environment variable names by converting to SCREAMING_SNAKE_CASE.
9
+ * e.g., "openai-api-key" → process.env.OPENAI_API_KEY
10
+ *
11
+ * @example
12
+ * ```typescript
13
+ * const provider = new EnvSecretProvider();
14
+ * // reads process.env.OPENAI_API_KEY
15
+ * const key = await provider.getSecret("openai-api-key");
16
+ * ```
17
+ */
18
+ export class EnvSecretProvider implements SecretProvider {
19
+ name = "env";
20
+
21
+ async getSecret(secretName: string): Promise<string | null> {
22
+ // Convert secret name to env var format: "openai-api-key" → "OPENAI_API_KEY"
23
+ const envKey = secretName.replace(/[-.]/g, "_").toUpperCase();
24
+ const value = process.env[envKey] ?? null;
25
+ if (value === null) {
26
+ logger.debug(`EnvSecretProvider: no env var found for ${secretName} (tried ${envKey})`);
27
+ }
28
+ return value;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Options for GcpSecretProvider.
34
+ */
35
+ export interface GcpSecretProviderOptions {
36
+ /** GCP project ID. Required for short secret names. */
37
+ projectId: string;
38
+ }
39
+
40
+ /**
41
+ * Secret provider that reads secrets from Google Cloud Secret Manager.
42
+ *
43
+ * Requires `@google-cloud/secret-manager` to be installed.
44
+ * Resolves short names like "openai-api-key" to the full resource path
45
+ * `projects/{projectId}/secrets/{secretName}/versions/latest`.
46
+ *
47
+ * @example
48
+ * ```typescript
49
+ * const provider = new GcpSecretProvider({ projectId: "my-project" });
50
+ * const key = await provider.getSecret("openai-api-key");
51
+ * ```
52
+ */
53
+ export class GcpSecretProvider implements SecretProvider {
54
+ name = "gcp";
55
+ private projectId: string;
56
+ private client: any = null;
57
+
58
+ constructor(options: GcpSecretProviderOptions) {
59
+ this.projectId = options.projectId;
60
+ }
61
+
62
+ private async getClient(): Promise<any> {
63
+ if (!this.client) {
64
+ try {
65
+ // Dynamic import — @google-cloud/secret-manager is an optional peer dependency
66
+ const moduleName = "@google-cloud/secret-manager";
67
+ const mod: any = await import(/* webpackIgnore: true */ moduleName);
68
+ const SecretManagerServiceClient =
69
+ mod.SecretManagerServiceClient ?? mod.default?.SecretManagerServiceClient;
70
+ this.client = new SecretManagerServiceClient();
71
+ } catch {
72
+ throw new Error(
73
+ "GcpSecretProvider requires @google-cloud/secret-manager. Install it with: bun add @google-cloud/secret-manager"
74
+ );
75
+ }
76
+ }
77
+ return this.client;
78
+ }
79
+
80
+ async getSecret(secretName: string): Promise<string | null> {
81
+ const client = await this.getClient();
82
+
83
+ let resourceName: string;
84
+ if (secretName.startsWith("projects/")) {
85
+ resourceName = secretName.endsWith("/versions/latest")
86
+ ? secretName
87
+ : `${secretName}/versions/latest`;
88
+ } else {
89
+ resourceName = `projects/${this.projectId}/secrets/${secretName}/versions/latest`;
90
+ }
91
+
92
+ try {
93
+ const [version] = await client.accessSecretVersion({name: resourceName});
94
+ const payload = version.payload?.data;
95
+ if (!payload) {
96
+ logger.warn(`GcpSecretProvider: secret ${secretName} has no payload`);
97
+ return null;
98
+ }
99
+ return typeof payload === "string" ? payload : new TextDecoder().decode(payload);
100
+ } catch (error: any) {
101
+ if (error?.code === 5) {
102
+ // NOT_FOUND
103
+ logger.warn(`GcpSecretProvider: secret ${secretName} not found`);
104
+ return null;
105
+ }
106
+ throw error;
107
+ }
108
+ }
109
+ }
package/src/terrenoApp.ts CHANGED
@@ -6,10 +6,12 @@ import qs from "qs";
6
6
 
7
7
  import type {ModelRouterRegistration} from "./api";
8
8
  import {addAuthRoutes, addMeRoutes, setupAuth, type UserModel as UserMongooseModel} from "./auth";
9
+ import {ConfigurationApp, type ConfigurationAppOptions} from "./configurationApp";
9
10
  import {apiErrorMiddleware, apiUnauthorizedMiddleware} from "./errors";
10
11
  import {type AuthOptions, logRequests} from "./expressServer";
11
12
  import {addGitHubAuthRoutes, type GitHubAuthOptions, setupGitHubAuth} from "./githubAuth";
12
13
  import {type LoggingOptions, logger, setupLogging} from "./logger";
14
+ import {openApiCompatMiddleware, patchAppUse} from "./openApiCompat";
13
15
  import {openApiEtagMiddleware} from "./openApiEtag";
14
16
  import type {TerrenoPlugin} from "./terrenoPlugin";
15
17
 
@@ -114,6 +116,7 @@ export class TerrenoApp {
114
116
  private options: TerrenoAppOptions;
115
117
  private registrations: (ModelRouterRegistration | TerrenoPlugin)[] = [];
116
118
  private middlewareFns: (express.RequestHandler | ((app: express.Application) => void))[] = [];
119
+ private configurationApp: ConfigurationApp | null = null;
117
120
 
118
121
  /**
119
122
  * Create a new TerrenoApp builder.
@@ -171,6 +174,36 @@ export class TerrenoApp {
171
174
  return this;
172
175
  }
173
176
 
177
+ /**
178
+ * Register a configuration model with the application.
179
+ *
180
+ * Adds configuration management endpoints that expose the model's schema
181
+ * as metadata, and provide GET/PATCH endpoints for reading and updating
182
+ * the singleton configuration document. Nested subschemas become separate
183
+ * sections in the admin UI.
184
+ *
185
+ * All configuration endpoints require admin authentication.
186
+ *
187
+ * @param model - Mongoose model with configurationPlugin applied
188
+ * @param options - Optional configuration (basePath, fieldOverrides)
189
+ * @returns This TerrenoApp instance for method chaining
190
+ *
191
+ * @example
192
+ * ```typescript
193
+ * const app = new TerrenoApp({ userModel: User })
194
+ * .configure(AppConfig)
195
+ * .register(todoRouter)
196
+ * .start();
197
+ * ```
198
+ */
199
+ configure(
200
+ model: import("mongoose").Model<any>,
201
+ options?: Omit<ConfigurationAppOptions, "model">
202
+ ): this {
203
+ this.configurationApp = new ConfigurationApp({model, ...options});
204
+ return this;
205
+ }
206
+
174
207
  /**
175
208
  * Build the Express application without starting the server.
176
209
  *
@@ -200,6 +233,9 @@ export class TerrenoApp {
200
233
  const app = express();
201
234
  const options = this.options;
202
235
 
236
+ // Record mount paths on layers for Express 5 → OpenAPI compat
237
+ patchAppUse(app);
238
+
203
239
  app.set("query parser", (str: string) =>
204
240
  qs.parse(str, {arrayLimit: options.arrayLimit ?? 200})
205
241
  );
@@ -217,7 +253,7 @@ export class TerrenoApp {
217
253
  }
218
254
  }
219
255
 
220
- app.use(express.json());
256
+ app.use(express.json({limit: "50mb"}));
221
257
 
222
258
  // Auth routes (login/signup/refresh_token) before JWT middleware
223
259
  addAuthRoutes(app, options.userModel as any, options.authOptions);
@@ -234,7 +270,7 @@ export class TerrenoApp {
234
270
  });
235
271
 
236
272
  // Sentry scopes
237
- app.all("*", (req: any, _res: any, next: any) => {
273
+ app.use((req: any, _res: any, next: any) => {
238
274
  const transactionId = req.header("X-Transaction-ID");
239
275
  const sessionId = req.header("X-Session-ID");
240
276
  if (transactionId) {
@@ -250,6 +286,7 @@ export class TerrenoApp {
250
286
  });
251
287
 
252
288
  // OpenAPI
289
+ app.use(openApiCompatMiddleware);
253
290
  app.use(openApiEtagMiddleware);
254
291
  const oapi = openapi({
255
292
  info: {
@@ -273,6 +310,11 @@ export class TerrenoApp {
273
310
  addGitHubAuthRoutes(app, options.userModel as any, options.githubAuth, options.authOptions);
274
311
  }
275
312
 
313
+ // Mount configuration app if configured
314
+ if (this.configurationApp) {
315
+ this.configurationApp.register(app);
316
+ }
317
+
276
318
  // Mount registered model routers and plugins
277
319
  for (const registration of this.registrations) {
278
320
  if (this.isModelRouterRegistration(registration)) {
package/src/tests.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import express, {type Express} from "express";
2
2
  import mongoose, {type Model, model, Schema} from "mongoose";
3
3
  import passportLocalMongoose from "passport-local-mongoose";
4
+ import qs from "qs";
4
5
  import supertest from "supertest";
5
6
  import type TestAgent from "supertest/lib/agent";
6
7
 
7
8
  import {logger} from "./logger";
9
+ import {patchAppUse} from "./openApiCompat";
8
10
  import {createdUpdatedPlugin, DateOnly, isDisabledPlugin} from "./plugins";
9
11
 
10
12
  export interface User {
@@ -162,8 +164,17 @@ export const RequiredModel = model<RequiredField>("Required", requiredSchema);
162
164
 
163
165
  export function getBaseServer(): Express {
164
166
  const app = express();
167
+ app.set("query parser", (str: string) => qs.parse(str, {arrayLimit: 200}));
165
168
 
166
- app.all("/*", (req, res, next) => {
169
+ // Express 5 defaults to 'simple' query parser (Node querystring) which doesn't
170
+ // support nested bracket notation like name[$regex]=Green. Use qs to match
171
+ // what setupServer() configures.
172
+ app.set("query parser", (str: string) => qs.parse(str, {arrayLimit: 200}));
173
+
174
+ // Record mount paths on layers for Express 5 → OpenAPI compat
175
+ patchAppUse(app);
176
+
177
+ app.use((req, res, next) => {
167
178
  res.header("Access-Control-Allow-Origin", "*");
168
179
  res.header("Access-Control-Allow-Headers", "*");
169
180
  // intercepts OPTIONS method