@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.
- package/dist/betterAuthSetup.js +10 -3
- package/dist/configurationPlugin.d.ts +2 -1
- package/dist/configurationPlugin.js +16 -9
- package/dist/errors.d.ts +6 -6
- package/dist/errors.js +22 -22
- package/dist/errors.test.d.ts +1 -0
- package/dist/errors.test.js +280 -0
- package/dist/githubAuth.d.ts +3 -3
- package/dist/githubAuth.js +16 -16
- package/dist/middleware.d.ts +1 -1
- package/dist/middleware.js +4 -3
- package/dist/middleware.test.d.ts +1 -0
- package/dist/middleware.test.js +82 -0
- package/dist/notifiers/googleChatNotifier.js +4 -3
- package/dist/notifiers/zoomNotifier.js +12 -11
- package/dist/openApiCompat.js +2 -1
- package/dist/openApiEtag.d.ts +1 -1
- package/dist/openApiEtag.js +4 -3
- package/dist/openApiValidator.d.ts +12 -12
- package/dist/openApiValidator.js +59 -58
- package/dist/plugins.d.ts +12 -1
- package/dist/plugins.js +34 -1
- package/dist/plugins.test.js +212 -8
- package/dist/scriptRunner.d.ts +8 -7
- package/dist/secretProviders.js +17 -7
- package/dist/types/consentForm.d.ts +4 -2
- package/package.json +1 -1
- package/src/betterAuthSetup.ts +10 -3
- package/src/configurationPlugin.ts +18 -9
- package/src/errors.test.ts +302 -0
- package/src/errors.ts +18 -13
- package/src/githubAuth.ts +11 -10
- package/src/middleware.test.ts +71 -0
- package/src/middleware.ts +6 -2
- package/src/notifiers/googleChatNotifier.ts +4 -3
- package/src/notifiers/zoomNotifier.ts +4 -3
- package/src/openApiCompat.ts +2 -1
- package/src/openApiEtag.ts +2 -2
- package/src/openApiValidator.ts +46 -46
- package/src/plugins.test.ts +130 -0
- package/src/plugins.ts +35 -0
- package/src/scriptRunner.ts +23 -27
- package/src/secretProviders.ts +27 -9
- package/src/types/consentForm.ts +6 -4
package/src/plugins.test.ts
CHANGED
|
@@ -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.
|
package/src/scriptRunner.ts
CHANGED
|
@@ -42,7 +42,7 @@ interface BackgroundTaskLog {
|
|
|
42
42
|
message: string;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
export
|
|
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
|
|
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
|
|
81
|
-
BackgroundTaskDocument,
|
|
82
|
-
|
|
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
|
{
|
package/src/secretProviders.ts
CHANGED
|
@@ -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:
|
|
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<
|
|
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
|
-
|
|
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
|
-
"
|
|
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:
|
|
101
|
-
if (error
|
|
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;
|
package/src/types/consentForm.ts
CHANGED
|
@@ -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
|
|
16
|
-
|
|
15
|
+
export interface ConsentFormStatics
|
|
16
|
+
extends FindExactlyOnePlugin<ConsentFormDocument>,
|
|
17
|
+
FindOneOrNonePlugin<ConsentFormDocument> {}
|
|
17
18
|
|
|
18
|
-
export
|
|
19
|
-
|
|
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;
|