@teamkeel/functions-runtime 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.
@@ -0,0 +1,360 @@
1
+ import { createJSONRPCRequest, JSONRPCErrorCode } from "json-rpc-2.0";
2
+ import { sql } from "kysely";
3
+ import { handleRequest, RuntimeErrors } from "./handleRequest";
4
+ import { test, expect, beforeEach, describe } from "vitest";
5
+ import { ModelAPI } from "./ModelAPI";
6
+ import { useDatabase } from "./database";
7
+ const { Permissions } = require("./permissions");
8
+ import { PROTO_ACTION_TYPES } from "./consts";
9
+ import KSUID from "ksuid";
10
+
11
+ test("when the custom function returns expected value", async () => {
12
+ const config = {
13
+ functions: {
14
+ createPost: async (ctx, inputs) => {
15
+ new Permissions().allow();
16
+
17
+ return {
18
+ title: "a post",
19
+ id: "abcde",
20
+ };
21
+ },
22
+ },
23
+ actionTypes: {
24
+ createPost: PROTO_ACTION_TYPES.CREATE,
25
+ },
26
+ createContextAPI: () => {},
27
+ };
28
+
29
+ const rpcReq = createJSONRPCRequest("123", "createPost", { title: "a post" });
30
+
31
+ expect(await handleRequest(rpcReq, config)).toEqual({
32
+ id: "123",
33
+ jsonrpc: "2.0",
34
+ meta: {
35
+ headers: {},
36
+ },
37
+ result: {
38
+ title: "a post",
39
+ id: "abcde",
40
+ },
41
+ });
42
+ });
43
+
44
+ test("when the custom function doesnt return a value", async () => {
45
+ const config = {
46
+ functions: {
47
+ createPost: async (ctx, inputs) => {
48
+ new Permissions().allow();
49
+ },
50
+ },
51
+ permissions: {},
52
+ actionTypes: {
53
+ createPost: PROTO_ACTION_TYPES.CREATE,
54
+ },
55
+ createContextAPI: () => {},
56
+ };
57
+
58
+ const rpcReq = createJSONRPCRequest("123", "createPost", { title: "a post" });
59
+
60
+ expect(await handleRequest(rpcReq, config)).toEqual({
61
+ id: "123",
62
+ jsonrpc: "2.0",
63
+ error: {
64
+ code: RuntimeErrors.NoResultError,
65
+ message: "no result returned from function 'createPost'",
66
+ },
67
+ });
68
+ });
69
+
70
+ test("when there is no matching function for the path", async () => {
71
+ const config = {
72
+ functions: {
73
+ createPost: async (ctx, inputs) => {},
74
+ },
75
+ actionTypes: {
76
+ createPost: PROTO_ACTION_TYPES.CREATE,
77
+ },
78
+ createContextAPI: () => {},
79
+ };
80
+
81
+ const rpcReq = createJSONRPCRequest("123", "unknown", { title: "a post" });
82
+
83
+ expect(await handleRequest(rpcReq, config)).toEqual({
84
+ id: "123",
85
+ jsonrpc: "2.0",
86
+ error: {
87
+ code: JSONRPCErrorCode.MethodNotFound,
88
+ message: "no corresponding function found for 'unknown'",
89
+ },
90
+ });
91
+ });
92
+
93
+ test("when there is an unexpected error in the custom function", async () => {
94
+ const config = {
95
+ functions: {
96
+ createPost: async (ctx, inputs) => {
97
+ throw new Error("oopsie daisy");
98
+ },
99
+ },
100
+ actionTypes: {
101
+ createPost: PROTO_ACTION_TYPES.CREATE,
102
+ },
103
+ createContextAPI: () => {},
104
+ };
105
+
106
+ const rpcReq = createJSONRPCRequest("123", "createPost", { title: "a post" });
107
+
108
+ expect(await handleRequest(rpcReq, config)).toEqual({
109
+ id: "123",
110
+ jsonrpc: "2.0",
111
+ error: {
112
+ code: RuntimeErrors.UnknownError,
113
+ message: "oopsie daisy",
114
+ },
115
+ });
116
+ });
117
+
118
+ test("when a role based permission has already been granted by the main runtime", async () => {
119
+ const config = {
120
+ functions: {
121
+ createPost: async (ctx, inputs, api) => {
122
+ return {
123
+ title: inputs.title,
124
+ };
125
+ },
126
+ },
127
+ actionTypes: {
128
+ createPost: PROTO_ACTION_TYPES.CREATE,
129
+ },
130
+ createModelAPI: () => {},
131
+ createContextAPI: () => {},
132
+ };
133
+
134
+ let rpcReq = createJSONRPCRequest("123", "createPost", { title: "a post" });
135
+
136
+ Object.assign(rpcReq, {
137
+ ...rpcReq,
138
+ meta: { permissionState: { status: "granted", reason: "role" } },
139
+ });
140
+ expect(await handleRequest(rpcReq, config)).toEqual({
141
+ id: "123",
142
+ jsonrpc: "2.0",
143
+ result: {
144
+ title: "a post",
145
+ },
146
+ meta: {
147
+ headers: {},
148
+ },
149
+ });
150
+ });
151
+
152
+ test("when there is an unexpected object thrown in the custom function", async () => {
153
+ const config = {
154
+ functions: {
155
+ createPost: async (ctx, inputs) => {
156
+ throw { err: "oopsie daisy" };
157
+ },
158
+ },
159
+ actionTypes: {
160
+ createPost: PROTO_ACTION_TYPES.CREATE,
161
+ },
162
+ createContextAPI: () => {},
163
+ };
164
+
165
+ const rpcReq = createJSONRPCRequest("123", "createPost", { title: "a post" });
166
+
167
+ expect(await handleRequest(rpcReq, config)).toEqual({
168
+ id: "123",
169
+ jsonrpc: "2.0",
170
+ error: {
171
+ code: RuntimeErrors.UnknownError,
172
+ message: '{"err":"oopsie daisy"}',
173
+ },
174
+ });
175
+ });
176
+
177
+ // The following tests assert on the various
178
+ // jsonrpc responses that *should* happen when a user
179
+ // writes a custom function that inadvertently causes a pg constraint error to occur inside of our ModelAPI class instance.
180
+ describe("ModelAPI error handling", () => {
181
+ let functionConfig;
182
+ let db;
183
+
184
+ beforeEach(async () => {
185
+ process.env.KEEL_DB_CONN_TYPE = "pg";
186
+ process.env.KEEL_DB_CONN = `postgresql://postgres:postgres@localhost:5432/functions-runtime`;
187
+
188
+ db = useDatabase();
189
+
190
+ await sql`
191
+ DROP TABLE IF EXISTS post;
192
+ DROP TABLE IF EXISTS author;
193
+
194
+ CREATE TABLE author(
195
+ "id" text PRIMARY KEY,
196
+ "name" text NOT NULL
197
+ );
198
+
199
+ CREATE TABLE post(
200
+ "id" text PRIMARY KEY,
201
+ "title" text NOT NULL UNIQUE,
202
+ "author_id" text NOT NULL REFERENCES author(id)
203
+ );
204
+ `.execute(db);
205
+
206
+ await sql`
207
+ INSERT INTO author (id, name) VALUES ('adam', 'adam bull')
208
+ `.execute(db);
209
+
210
+ const models = {
211
+ post: new ModelAPI("post", undefined, {
212
+ post: {
213
+ author: {
214
+ relationshipType: "belongsTo",
215
+ foreignKey: "author_id",
216
+ referencesTable: "person",
217
+ },
218
+ },
219
+ }),
220
+ };
221
+
222
+ functionConfig = {
223
+ permissionFns: {},
224
+ actionTypes: {
225
+ createPost: PROTO_ACTION_TYPES.CREATE,
226
+ deletePost: PROTO_ACTION_TYPES.DELETE,
227
+ },
228
+ functions: {
229
+ createPost: async (ctx, inputs) => {
230
+ new Permissions().allow();
231
+
232
+ const post = await models.post.create({
233
+ id: KSUID.randomSync().string,
234
+ ...inputs,
235
+ });
236
+
237
+ return post;
238
+ },
239
+ deletePost: async (ctx, inputs) => {
240
+ new Permissions().allow();
241
+
242
+ const deleted = await models.post.delete(inputs);
243
+
244
+ return deleted;
245
+ },
246
+ },
247
+ createContextAPI: () => ({}),
248
+ };
249
+ });
250
+
251
+ test("when kysely returns a no result error", async () => {
252
+ // a kysely NoResultError is thrown when attempting to delete/update a non existent record.
253
+ const rpcReq = createJSONRPCRequest("123", "deletePost", {
254
+ id: "non-existent-id",
255
+ });
256
+
257
+ expect(await handleRequest(rpcReq, functionConfig)).toEqual({
258
+ id: "123",
259
+ jsonrpc: "2.0",
260
+ error: {
261
+ code: RuntimeErrors.RecordNotFoundError,
262
+ message: "no result",
263
+ },
264
+ });
265
+ });
266
+
267
+ test("when there is a not null constraint error", async () => {
268
+ const rpcReq = createJSONRPCRequest("123", "createPost", { title: null });
269
+
270
+ expect(await handleRequest(rpcReq, functionConfig)).toEqual({
271
+ id: "123",
272
+ jsonrpc: "2.0",
273
+ error: {
274
+ code: RuntimeErrors.NotNullConstraintError,
275
+ message: 'null value in column "title" violates not-null constraint',
276
+ data: {
277
+ code: "23502",
278
+ column: "title",
279
+ detail: expect.stringContaining("Failing row contains"),
280
+ table: "post",
281
+ },
282
+ },
283
+ });
284
+ });
285
+
286
+ test("when there is a uniqueness constraint error", async () => {
287
+ await sql`
288
+
289
+ INSERT INTO post (id, title, author_id) values(${
290
+ KSUID.randomSync().string
291
+ }, 'hello', 'adam')
292
+ `.execute(db);
293
+
294
+ const rpcReq = createJSONRPCRequest("123", "createPost", {
295
+ title: "hello",
296
+ author_id: "something",
297
+ });
298
+
299
+ expect(await handleRequest(rpcReq, functionConfig)).toEqual({
300
+ id: "123",
301
+ jsonrpc: "2.0",
302
+ error: {
303
+ code: RuntimeErrors.UniqueConstraintError,
304
+ message:
305
+ 'duplicate key value violates unique constraint "post_title_key"',
306
+ data: {
307
+ code: "23505",
308
+ column: "title",
309
+ detail: "Key (title)=(hello) already exists.",
310
+ table: "post",
311
+ value: "hello",
312
+ },
313
+ },
314
+ });
315
+ });
316
+
317
+ test("when there is a null value in a foreign key column", async () => {
318
+ const rpcReq = createJSONRPCRequest("123", "createPost", { title: "123" });
319
+
320
+ expect(await handleRequest(rpcReq, functionConfig)).toEqual({
321
+ id: "123",
322
+ jsonrpc: "2.0",
323
+ error: {
324
+ code: RuntimeErrors.NotNullConstraintError,
325
+ message:
326
+ 'null value in column "author_id" violates not-null constraint',
327
+ data: {
328
+ code: "23502",
329
+ column: "author_id",
330
+ detail: expect.stringContaining("Failing row contains"),
331
+ table: "post",
332
+ },
333
+ },
334
+ });
335
+ });
336
+
337
+ test("when there is a foreign key constraint violation", async () => {
338
+ const rpcReq2 = createJSONRPCRequest("123", "createPost", {
339
+ title: "123",
340
+ author_id: "fake",
341
+ });
342
+
343
+ expect(await handleRequest(rpcReq2, functionConfig)).toEqual({
344
+ id: "123",
345
+ jsonrpc: "2.0",
346
+ error: {
347
+ code: RuntimeErrors.ForeignKeyConstraintError,
348
+ message:
349
+ 'insert or update on table "post" violates foreign key constraint "post_author_id_fkey"',
350
+ data: {
351
+ code: "23503",
352
+ column: "author_id",
353
+ detail: 'Key (author_id)=(fake) is not present in table "author".',
354
+ table: "post",
355
+ value: "fake",
356
+ },
357
+ },
358
+ });
359
+ });
360
+ });
package/src/index.d.ts ADDED
@@ -0,0 +1,86 @@
1
+ export type IDWhereCondition = {
2
+ equals?: string | null;
3
+ oneOf?: string[] | null;
4
+ };
5
+
6
+ export type StringWhereCondition = {
7
+ startsWith?: string | null;
8
+ endsWith?: string | null;
9
+ oneOf?: string[] | null;
10
+ contains?: string | null;
11
+ notEquals?: string | null;
12
+ equals?: string | null;
13
+ };
14
+
15
+ export type BooleanWhereCondition = {
16
+ equals?: boolean | null;
17
+ notEquals?: boolean | null;
18
+ };
19
+
20
+ export type NumberWhereCondition = {
21
+ greaterThan?: number | null;
22
+ greaterThanOrEquals?: number | null;
23
+ lessThan?: number | null;
24
+ lessThanOrEquals?: number | null;
25
+ equals?: number | null;
26
+ notEquals?: number | null;
27
+ };
28
+
29
+ // Date database API
30
+ export type DateWhereCondition = {
31
+ equals?: Date | string | null;
32
+ before?: Date | string | null;
33
+ onOrBefore?: Date | string | null;
34
+ after?: Date | string | null;
35
+ onOrAfter?: Date | string | null;
36
+ };
37
+
38
+ // Date query input
39
+ export type DateQueryInput = {
40
+ equals?: string | null;
41
+ before?: string | null;
42
+ onOrBefore?: string | null;
43
+ after?: string | null;
44
+ onOrAfter?: string | null;
45
+ };
46
+
47
+ // Timestamp query input
48
+ export type TimestampQueryInput = {
49
+ before: string | null;
50
+ after: string | null;
51
+ };
52
+
53
+ export type ContextAPI = {
54
+ headers: RequestHeaders;
55
+ response: Response;
56
+ isAuthenticated: boolean;
57
+ now(): Date;
58
+ };
59
+
60
+ export type Response = {
61
+ headers: Headers;
62
+ };
63
+
64
+ export type PageInfo = {
65
+ startCursor: string;
66
+ endCursor: string;
67
+ totalCount: number;
68
+ hasNextPage: boolean;
69
+ count: number;
70
+ };
71
+
72
+ // Request headers query API
73
+ export type RequestHeaders = {
74
+ get(name: string): string;
75
+ has(name: string): boolean;
76
+ };
77
+
78
+ export declare class Permissions {
79
+ constructor();
80
+
81
+ // allow() can be used to explicitly permit access to an action
82
+ allow(): void;
83
+
84
+ // deny() can be used to explicitly deny access to an action
85
+ deny(): never;
86
+ }
package/src/index.js ADDED
@@ -0,0 +1,27 @@
1
+ const { ModelAPI } = require("./ModelAPI");
2
+ const { RequestHeaders } = require("./RequestHeaders");
3
+ const { handleRequest } = require("./handleRequest");
4
+ const { handleJob } = require("./handleJob");
5
+ const KSUID = require("ksuid");
6
+ const { useDatabase } = require("./database");
7
+ const {
8
+ Permissions,
9
+ PERMISSION_STATE,
10
+ checkBuiltInPermissions,
11
+ } = require("./permissions");
12
+ const tracing = require("./tracing");
13
+
14
+ module.exports = {
15
+ ModelAPI,
16
+ RequestHeaders,
17
+ handleRequest,
18
+ handleJob,
19
+ useDatabase,
20
+ Permissions,
21
+ PERMISSION_STATE,
22
+ checkBuiltInPermissions,
23
+ tracing,
24
+ ksuid() {
25
+ return KSUID.randomSync().string;
26
+ },
27
+ };
@@ -0,0 +1,78 @@
1
+ const { AsyncLocalStorage } = require("async_hooks");
2
+
3
+ class PermissionError extends Error {}
4
+
5
+ const PERMISSION_STATE = {
6
+ UNKNOWN: "unknown",
7
+ PERMITTED: "permitted",
8
+ UNPERMITTED: "unpermitted",
9
+ };
10
+
11
+ // withPermissions sets the initial permission state from the go runtime in the AsyncLocalStorage so consumers further down the hierarchy can read or mutate the state
12
+ // at will
13
+ const withPermissions = async (initialValue, cb) => {
14
+ const permissions = new Permissions();
15
+
16
+ return await permissionsApiInstance.run({ permitted: initialValue }, () => {
17
+ return cb({ getPermissionState: permissions.getState });
18
+ });
19
+ };
20
+
21
+ const permissionsApiInstance = new AsyncLocalStorage();
22
+
23
+ class Permissions {
24
+ // The Go runtime performs role based permission rule checks prior to calling the functions
25
+ // runtime, so the status could already be granted. If already granted, then we need to inherit that permission state as the state is later used to decide whether to run in process permission checks
26
+ // TLDR if a role based permission is relevant and it is granted, then it is effectively the same as the end user calling api.permissions.allow() explicitly in terms of behaviour.
27
+
28
+ allow() {
29
+ const store = (permissionsApiInstance.getStore().permitted = true);
30
+ }
31
+
32
+ deny() {
33
+ // if a user is explicitly calling deny() then we want to throw an error
34
+ // so that any further execution of the custom function stops abruptly
35
+ permissionsApiInstance.getStore().permitted = false;
36
+
37
+ throw new PermissionError();
38
+ }
39
+
40
+ getState() {
41
+ const permitted = permissionsApiInstance.getStore().permitted;
42
+
43
+ switch (true) {
44
+ case permitted === false:
45
+ return PERMISSION_STATE.UNPERMITTED;
46
+ case permitted === null:
47
+ return PERMISSION_STATE.UNKNOWN;
48
+ case permitted === true:
49
+ return PERMISSION_STATE.PERMITTED;
50
+ }
51
+ }
52
+ }
53
+
54
+ const checkBuiltInPermissions = async ({
55
+ rows,
56
+ permissionFns,
57
+ ctx,
58
+ db,
59
+ functionName,
60
+ }) => {
61
+ for (const permissionFn of permissionFns) {
62
+ const result = await permissionFn(rows, ctx, db);
63
+ // if any of the permission functions return true,
64
+ // then we return early
65
+ if (result) {
66
+ return;
67
+ }
68
+ }
69
+
70
+ throw new PermissionError(`Not permitted to access ${functionName}`);
71
+ };
72
+
73
+ module.exports.checkBuiltInPermissions = checkBuiltInPermissions;
74
+ module.exports.PermissionError = PermissionError;
75
+ module.exports.PERMISSION_STATE = PERMISSION_STATE;
76
+ module.exports.Permissions = Permissions;
77
+ module.exports.withPermissions = withPermissions;
78
+ module.exports.permissionsApiInstance = permissionsApiInstance;
@@ -0,0 +1,120 @@
1
+ const {
2
+ permissionsApiInstance,
3
+ Permissions,
4
+ PERMISSION_STATE,
5
+ PermissionError,
6
+ checkBuiltInPermissions,
7
+ } = require("./permissions");
8
+
9
+ import { useDatabase } from "./database";
10
+
11
+ import { beforeEach, describe, expect, test } from "vitest";
12
+
13
+ let permissions;
14
+ let ctx = {};
15
+ let db = useDatabase();
16
+
17
+ describe("explicit", () => {
18
+ beforeEach(() => {
19
+ permissions = new Permissions();
20
+ });
21
+
22
+ test("explicitly allowing execution", () => {
23
+ wrapWithAsyncLocalStorage({ permitted: null }, () => {
24
+ expect(permissions.getState()).toEqual(PERMISSION_STATE.UNKNOWN);
25
+
26
+ permissions.allow();
27
+
28
+ expect(permissions.getState()).toEqual(PERMISSION_STATE.PERMITTED);
29
+ });
30
+ });
31
+
32
+ test("explicitly denying execution", () => {
33
+ wrapWithAsyncLocalStorage({ permitted: null }, () => {
34
+ expect(permissions.getState()).toEqual(PERMISSION_STATE.UNKNOWN);
35
+
36
+ expect(() => permissions.deny()).toThrowError(PermissionError);
37
+
38
+ expect(permissions.getState()).toEqual(PERMISSION_STATE.UNPERMITTED);
39
+ });
40
+ });
41
+ });
42
+
43
+ describe("prior state", () => {
44
+ test("when the prior state is granted", () => {
45
+ wrapWithAsyncLocalStorage(
46
+ {
47
+ permitted: true,
48
+ },
49
+ () => {
50
+ expect(permissions.getState()).toEqual(PERMISSION_STATE.PERMITTED);
51
+ }
52
+ );
53
+ });
54
+ });
55
+
56
+ describe("check", () => {
57
+ const functionName = "createPerson";
58
+
59
+ test("check - success", async () => {
60
+ const permissionRule1 = (records, ctx, db) => {
61
+ // Only allow names starting with Adam
62
+ return records.every((r) => r.name.startsWith("Adam"));
63
+ };
64
+
65
+ const rows = [
66
+ {
67
+ id: "123",
68
+ name: "Adam Bull",
69
+ },
70
+ {
71
+ id: "234",
72
+ name: "Adam Lambert",
73
+ },
74
+ ];
75
+
76
+ await expect(
77
+ checkBuiltInPermissions({
78
+ rows,
79
+ ctx,
80
+ db,
81
+ functionName,
82
+ permissionFns: [permissionRule1],
83
+ })
84
+ ).resolves.ok;
85
+ });
86
+
87
+ test("check - failure", async () => {
88
+ // only allow Petes
89
+ const permissionRule1 = (records, ctx, db) => {
90
+ return records.every((r) => r.name === "Pete");
91
+ };
92
+
93
+ const rows = [
94
+ {
95
+ id: "123",
96
+ name: "Adam", // this one will cause an error to be thrown because Adam is not Pete
97
+ },
98
+ {
99
+ id: "234",
100
+ name: "Pete",
101
+ },
102
+ ];
103
+
104
+ await expect(
105
+ checkBuiltInPermissions({
106
+ rows,
107
+ ctx,
108
+ db,
109
+ functionName,
110
+ permissionFns: [permissionRule1],
111
+ })
112
+ ).rejects.toThrow();
113
+ });
114
+ });
115
+
116
+ function wrapWithAsyncLocalStorage(initialState, testFn) {
117
+ permissionsApiInstance.run(initialState, () => {
118
+ testFn();
119
+ });
120
+ }