@terreno/api 0.11.7 → 0.11.9

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 (44) hide show
  1. package/dist/betterAuthSetup.js +10 -3
  2. package/dist/configurationPlugin.d.ts +2 -1
  3. package/dist/configurationPlugin.js +16 -9
  4. package/dist/errors.d.ts +6 -6
  5. package/dist/errors.js +22 -22
  6. package/dist/errors.test.d.ts +1 -0
  7. package/dist/errors.test.js +280 -0
  8. package/dist/githubAuth.d.ts +3 -3
  9. package/dist/githubAuth.js +16 -16
  10. package/dist/middleware.d.ts +1 -1
  11. package/dist/middleware.js +4 -3
  12. package/dist/middleware.test.d.ts +1 -0
  13. package/dist/middleware.test.js +82 -0
  14. package/dist/notifiers/googleChatNotifier.js +4 -3
  15. package/dist/notifiers/zoomNotifier.js +12 -11
  16. package/dist/openApiCompat.js +2 -1
  17. package/dist/openApiEtag.d.ts +1 -1
  18. package/dist/openApiEtag.js +4 -3
  19. package/dist/openApiValidator.d.ts +12 -12
  20. package/dist/openApiValidator.js +59 -58
  21. package/dist/plugins.d.ts +12 -1
  22. package/dist/plugins.js +34 -1
  23. package/dist/plugins.test.js +212 -8
  24. package/dist/scriptRunner.d.ts +8 -7
  25. package/dist/secretProviders.js +17 -7
  26. package/dist/types/consentForm.d.ts +4 -2
  27. package/package.json +1 -1
  28. package/src/betterAuthSetup.ts +10 -3
  29. package/src/configurationPlugin.ts +18 -9
  30. package/src/errors.test.ts +302 -0
  31. package/src/errors.ts +18 -13
  32. package/src/githubAuth.ts +11 -10
  33. package/src/middleware.test.ts +71 -0
  34. package/src/middleware.ts +6 -2
  35. package/src/notifiers/googleChatNotifier.ts +4 -3
  36. package/src/notifiers/zoomNotifier.ts +4 -3
  37. package/src/openApiCompat.ts +2 -1
  38. package/src/openApiEtag.ts +2 -2
  39. package/src/openApiValidator.ts +46 -46
  40. package/src/plugins.test.ts +130 -0
  41. package/src/plugins.ts +35 -0
  42. package/src/scriptRunner.ts +23 -27
  43. package/src/secretProviders.ts +27 -9
  44. package/src/types/consentForm.ts +6 -4
@@ -8,10 +8,13 @@ import {addAuthRoutes, setupAuth} from "./auth";
8
8
  import type {APIErrorConstructor} from "./errors";
9
9
  import {Permissions} from "./permissions";
10
10
  import {
11
+ baseUserPlugin,
11
12
  createdUpdatedPlugin,
12
13
  DateOnly,
13
14
  findExactlyOne,
14
15
  findOneOrNone,
16
+ findOneOrNoneFor,
17
+ firebaseJWTPlugin,
15
18
  type IsDeleted,
16
19
  isDeletedPlugin,
17
20
  upsertPlugin,
@@ -52,6 +55,33 @@ stuffSchema.plugin(createdUpdatedPlugin);
52
55
 
53
56
  const StuffModel = model<Stuff>("Stuff", stuffSchema) as unknown as StuffModelType;
54
57
 
58
+ describe("baseUserPlugin", () => {
59
+ it("adds admin and email fields to the schema", () => {
60
+ const testSchema = new Schema({});
61
+ // biome-ignore lint/suspicious/noExplicitAny: test schema
62
+ baseUserPlugin(testSchema as Schema<any, any, any, any>);
63
+
64
+ const adminPath = testSchema.path("admin");
65
+ expect(adminPath).toBeDefined();
66
+ expect((adminPath as unknown as {options: {default: boolean}}).options.default).toBe(false);
67
+
68
+ const emailPath = testSchema.path("email");
69
+ expect(emailPath).toBeDefined();
70
+ expect((emailPath as unknown as {options: {index: boolean}}).options.index).toBe(true);
71
+ });
72
+ });
73
+
74
+ describe("firebaseJWTPlugin", () => {
75
+ it("adds firebaseId field to the schema", () => {
76
+ const testSchema = new Schema({});
77
+ firebaseJWTPlugin(testSchema);
78
+
79
+ const firebaseIdPath = testSchema.path("firebaseId");
80
+ expect(firebaseIdPath).toBeDefined();
81
+ expect((firebaseIdPath as unknown as {options: {index: boolean}}).options.index).toBe(true);
82
+ });
83
+ });
84
+
55
85
  describe("createdUpdate", () => {
56
86
  it("sets created and updated on save", async () => {
57
87
  setSystemTime(new Date("2022-12-17T03:24:00.000Z"));
@@ -158,6 +188,106 @@ describe("findOneOrNone", () => {
158
188
  });
159
189
  });
160
190
 
191
+ interface BareThing {
192
+ name: string;
193
+ ownerId: string;
194
+ }
195
+
196
+ const bareThingSchema = new Schema<BareThing>({
197
+ name: {description: "The name of the bare item", type: String},
198
+ ownerId: {description: "The owner of the bare item", type: String},
199
+ });
200
+
201
+ const BareThingModel = model<BareThing>("BareThing", bareThingSchema);
202
+
203
+ describe("findOneOrNoneFor", () => {
204
+ beforeEach(async () => {
205
+ await StuffModel.deleteMany({});
206
+ await BareThingModel.deleteMany({});
207
+ await setupDb();
208
+ });
209
+
210
+ describe("when the schema has the findOneOrNone plugin", () => {
211
+ let things: any;
212
+
213
+ beforeEach(async () => {
214
+ [things] = await Promise.all([
215
+ StuffModel.create({name: "Things", ownerId: "123"}),
216
+ StuffModel.create({name: "StuffNThings", ownerId: "123"}),
217
+ ]);
218
+ });
219
+
220
+ it("returns null with no matches", async () => {
221
+ const result = await findOneOrNoneFor(StuffModel, {name: "OtherStuff"});
222
+ expect(result).toBeNull();
223
+ });
224
+
225
+ it("returns a single match", async () => {
226
+ const result = await findOneOrNoneFor(StuffModel, {name: "Things"});
227
+ expect(result).not.toBeNull();
228
+ expect(result?._id.toString()).toBe(things._id.toString());
229
+ });
230
+
231
+ it("throws when multiple documents match", async () => {
232
+ const fn = () => findOneOrNoneFor(StuffModel, {ownerId: "123"});
233
+ await expect(fn()).rejects.toThrow(/Stuff\.findOne query returned multiple documents/);
234
+ });
235
+
236
+ it("forwards errorArgs to the thrown APIError", async () => {
237
+ const fn = () =>
238
+ findOneOrNoneFor(StuffModel, {ownerId: "123"}, {status: 400, title: "Oh no!"});
239
+
240
+ try {
241
+ await fn();
242
+ throw new Error("Expected promise to reject");
243
+ } catch (error: any) {
244
+ expect(error.title).toBe("Oh no!");
245
+ expect(error.status).toBe(400);
246
+ }
247
+ });
248
+ });
249
+
250
+ describe("when the schema does NOT have the findOneOrNone plugin", () => {
251
+ let bareThings: any;
252
+
253
+ beforeEach(async () => {
254
+ [bareThings] = await Promise.all([
255
+ BareThingModel.create({name: "Things", ownerId: "123"}),
256
+ BareThingModel.create({name: "StuffNThings", ownerId: "123"}),
257
+ ]);
258
+ });
259
+
260
+ it("returns null with no matches", async () => {
261
+ const result = await findOneOrNoneFor(BareThingModel, {name: "OtherStuff"});
262
+ expect(result).toBeNull();
263
+ });
264
+
265
+ it("returns a single match", async () => {
266
+ const result = await findOneOrNoneFor(BareThingModel, {name: "Things"});
267
+ expect(result).not.toBeNull();
268
+ expect((result as any)?._id?.toString()).toBe(bareThings._id?.toString());
269
+ });
270
+
271
+ it("throws when multiple documents match", async () => {
272
+ const fn = () => findOneOrNoneFor(BareThingModel, {ownerId: "123"});
273
+ await expect(fn()).rejects.toThrow(/BareThing\.findOne query returned multiple documents/);
274
+ });
275
+
276
+ it("forwards errorArgs to the thrown APIError", async () => {
277
+ const fn = () =>
278
+ findOneOrNoneFor(BareThingModel, {ownerId: "123"}, {status: 400, title: "Oh no!"});
279
+
280
+ try {
281
+ await fn();
282
+ throw new Error("Expected promise to reject");
283
+ } catch (error: any) {
284
+ expect(error.title).toBe("Oh no!");
285
+ expect(error.status).toBe(400);
286
+ }
287
+ });
288
+ });
289
+ });
290
+
161
291
  describe("findExactlyOne", () => {
162
292
  let things: any;
163
293
 
package/src/plugins.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import {DateTime} from "luxon";
2
2
  import mongoose, {
3
3
  type Document,
4
+ type FilterQuery,
4
5
  Error as MongooseError,
5
6
  type Query,
6
7
  type Schema,
@@ -129,6 +130,40 @@ export const findOneOrNone = <T>(schema: Schema<T>): void => {
129
130
  };
130
131
  };
131
132
 
133
+ /**
134
+ * Helper that performs a `findOneOrNone` lookup against any Mongoose model. Returns the matching
135
+ * document, `null` if none match, or throws if more than one matches. If the model's schema has
136
+ * the {@link findOneOrNone} plugin applied, the plugin static is used; otherwise the lookup is
137
+ * performed directly via `model.find(...)`. Prefer this helper from framework code where the
138
+ * consumer's model may or may not have the plugin installed.
139
+ * @param model Mongoose Model
140
+ * @param query Mongoose query object
141
+ * @param errorArgs Optional overrides for the thrown {@link APIError} when multiple match
142
+ */
143
+ export const findOneOrNoneFor = async <T>(
144
+ model: mongoose.Model<T>,
145
+ query: FilterQuery<T>,
146
+ errorArgs?: Partial<APIErrorConstructor>
147
+ ): Promise<(Document & T) | null> => {
148
+ const withStatic = model as mongoose.Model<T> & Partial<FindOneOrNonePlugin<T>>;
149
+ if (typeof withStatic.findOneOrNone === "function") {
150
+ return withStatic.findOneOrNone(query, errorArgs);
151
+ }
152
+ const results = await model.find(query);
153
+ if (results.length === 0) {
154
+ return null;
155
+ }
156
+ if (results.length > 1) {
157
+ throw new APIError({
158
+ detail: `query: ${JSON.stringify(query)}`,
159
+ status: 500,
160
+ title: `${model.modelName}.findOne query returned multiple documents`,
161
+ ...errorArgs,
162
+ });
163
+ }
164
+ return results[0] as unknown as Document & T;
165
+ };
166
+
132
167
  /**
133
168
  * This adds a static method `Model.findExactlyOne` to the schema. This or findOneOrNone should replace `Model.findOne`
134
169
  * in most instances.
@@ -42,7 +42,7 @@ interface BackgroundTaskLog {
42
42
  message: string;
43
43
  }
44
44
 
45
- export type BackgroundTaskMethods = {
45
+ export interface BackgroundTaskMethods {
46
46
  addLog: (
47
47
  this: BackgroundTaskDocument,
48
48
  level: "info" | "warn" | "error",
@@ -54,35 +54,31 @@ export type BackgroundTaskMethods = {
54
54
  stage?: string,
55
55
  message?: string
56
56
  ) => Promise<void>;
57
- };
57
+ }
58
+
59
+ export interface BackgroundTaskDocument extends Document, BackgroundTaskMethods {
60
+ taskType: string;
61
+ status: "pending" | "running" | "completed" | "failed" | "cancelled";
62
+ progress?: BackgroundTaskProgress;
63
+ createdBy?: mongoose.Types.ObjectId;
64
+ isDryRun: boolean;
65
+ result?: string[];
66
+ error?: string;
67
+ logs: BackgroundTaskLog[];
68
+ startedAt?: Date;
69
+ completedAt?: Date;
70
+ created: Date;
71
+ updated: Date;
72
+ deleted: boolean;
73
+ }
58
74
 
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 = {
75
+ export interface BackgroundTaskStatics {
77
76
  checkCancellation: (taskId: string) => Promise<void>;
78
- };
77
+ }
79
78
 
80
- export type BackgroundTaskModel = Model<
81
- BackgroundTaskDocument,
82
- Record<string, never>,
83
- BackgroundTaskMethods
84
- > &
85
- BackgroundTaskStatics;
79
+ export interface BackgroundTaskModel
80
+ extends Model<BackgroundTaskDocument, Record<string, never>, BackgroundTaskMethods>,
81
+ BackgroundTaskStatics {}
86
82
 
87
83
  const progressSchema = new Schema(
88
84
  {
@@ -1,6 +1,16 @@
1
1
  import type {SecretProvider} from "./configurationPlugin";
2
+ import {APIError} from "./errors";
2
3
  import {logger} from "./logger";
3
4
 
5
+ interface SecretManagerClient {
6
+ accessSecretVersion(request: {name: string}): Promise<[{payload?: {data?: string | Uint8Array}}]>;
7
+ }
8
+
9
+ interface SecretManagerModule {
10
+ SecretManagerServiceClient?: new () => SecretManagerClient;
11
+ default?: {SecretManagerServiceClient?: new () => SecretManagerClient};
12
+ }
13
+
4
14
  /**
5
15
  * Secret provider that reads secrets from environment variables.
6
16
  * Useful for local development and testing.
@@ -53,26 +63,34 @@ export interface GcpSecretProviderOptions {
53
63
  export class GcpSecretProvider implements SecretProvider {
54
64
  name = "gcp";
55
65
  private projectId: string;
56
- private client: any = null;
66
+ private client: SecretManagerClient | null = null;
57
67
 
58
68
  constructor(options: GcpSecretProviderOptions) {
59
69
  this.projectId = options.projectId;
60
70
  }
61
71
 
62
- private async getClient(): Promise<any> {
72
+ private async getClient(): Promise<SecretManagerClient> {
63
73
  if (!this.client) {
74
+ let mod: SecretManagerModule;
64
75
  try {
65
76
  // Dynamic import — @google-cloud/secret-manager is an optional peer dependency
66
77
  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();
78
+ mod = await import(/* webpackIgnore: true */ moduleName);
71
79
  } catch {
80
+ throw new APIError({
81
+ status: 500,
82
+ title:
83
+ "GcpSecretProvider requires @google-cloud/secret-manager. Install it with: bun add @google-cloud/secret-manager",
84
+ });
85
+ }
86
+ const SecretManagerServiceClient =
87
+ mod.SecretManagerServiceClient ?? mod.default?.SecretManagerServiceClient;
88
+ if (!SecretManagerServiceClient) {
72
89
  throw new Error(
73
- "GcpSecretProvider requires @google-cloud/secret-manager. Install it with: bun add @google-cloud/secret-manager"
90
+ "SecretManagerServiceClient not found in @google-cloud/secret-manager module"
74
91
  );
75
92
  }
93
+ this.client = new SecretManagerServiceClient();
76
94
  }
77
95
  return this.client;
78
96
  }
@@ -97,8 +115,8 @@ export class GcpSecretProvider implements SecretProvider {
97
115
  return null;
98
116
  }
99
117
  return typeof payload === "string" ? payload : new TextDecoder().decode(payload);
100
- } catch (error: any) {
101
- if (error?.code === 5) {
118
+ } catch (error: unknown) {
119
+ if (error instanceof Error && "code" in error && (error as {code: number}).code === 5) {
102
120
  // NOT_FOUND
103
121
  logger.warn(`GcpSecretProvider: secret ${secretName} not found`);
104
122
  return null;
@@ -12,11 +12,13 @@ export type ConsentFormType = "agreement" | "privacy" | "hipaa" | "research" | "
12
12
  // biome-ignore lint/complexity/noBannedTypes: No methods.
13
13
  export type ConsentFormMethods = {};
14
14
 
15
- export type ConsentFormStatics = FindExactlyOnePlugin<ConsentFormDocument> &
16
- FindOneOrNonePlugin<ConsentFormDocument>;
15
+ export interface ConsentFormStatics
16
+ extends FindExactlyOnePlugin<ConsentFormDocument>,
17
+ FindOneOrNonePlugin<ConsentFormDocument> {}
17
18
 
18
- export type ConsentFormModel = mongoose.Model<ConsentFormDocument, object, ConsentFormMethods> &
19
- ConsentFormStatics;
19
+ export interface ConsentFormModel
20
+ extends mongoose.Model<ConsentFormDocument, object, ConsentFormMethods>,
21
+ ConsentFormStatics {}
20
22
 
21
23
  export interface ConsentFormDocument extends mongoose.Document {
22
24
  _id: mongoose.Types.ObjectId;