diff --git a/apps/api/src/pkg/key_migration/handler.ts b/apps/api/src/pkg/key_migration/handler.ts index d3875a8837..1696a18463 100644 --- a/apps/api/src/pkg/key_migration/handler.ts +++ b/apps/api/src/pkg/key_migration/handler.ts @@ -96,6 +96,7 @@ export async function migrateKey( expires: message.expires ? new Date(message.expires) : null, refillInterval: message.refill?.interval, refillAmount: message.refill?.amount, + refillDay: message.refill?.refillDay, enabled: message.enabled, remaining: message.remaining, ratelimitAsync: message.ratelimit?.async, diff --git a/apps/api/src/pkg/key_migration/message.ts b/apps/api/src/pkg/key_migration/message.ts index 33cd34c509..91a9e39ccc 100644 --- a/apps/api/src/pkg/key_migration/message.ts +++ b/apps/api/src/pkg/key_migration/message.ts @@ -14,7 +14,7 @@ export type MessageBody = { permissions?: string[]; expires?: number; remaining?: number; - refill?: { interval: "daily" | "monthly"; amount: number }; + refill?: { interval: "daily" | "monthly"; amount: number; refillDay?: number }; ratelimit?: { async: boolean; limit: number; duration: number }; enabled: boolean; environment?: string; diff --git a/apps/api/src/routes/schema.ts b/apps/api/src/routes/schema.ts index 1652e8e92f..a9d4d4b14b 100644 --- a/apps/api/src/routes/schema.ts +++ b/apps/api/src/routes/schema.ts @@ -59,13 +59,19 @@ export const keySchema = z refill: z .object({ interval: z.enum(["daily", "monthly"]).openapi({ - description: "Determines the rate at which verifications will be refilled.", + description: + "Determines the rate at which verifications will be refilled. When 'daily' is set for 'interval' 'refillDay' will be set to null.", example: "daily", }), amount: z.number().int().openapi({ description: "Resets `remaining` to this value every interval.", example: 100, }), + refillDay: z.number().min(1).max(31).default(1).nullable().openapi({ + description: + "The day verifications will refill each month, when interval is set to 'monthly'. Value is not zero-indexed making 1 the first day of the month. If left blank it will default to the first day of the month. When 'daily' is set for 'interval' 'refillDay' will be set to null.", + example: 15, + }), lastRefillAt: z.number().int().optional().openapi({ description: "The unix timestamp in miliseconds when the key was last refilled.", example: 100, @@ -76,10 +82,12 @@ export const keySchema = z description: "Unkey allows you to refill remaining verifications on a key on a regular interval.", example: { - interval: "daily", + interval: "monthly", amount: 10, + refillDay: 10, }, }), + ratelimit: z .object({ async: z.boolean().openapi({ diff --git a/apps/api/src/routes/v1_apis_listKeys.ts b/apps/api/src/routes/v1_apis_listKeys.ts index c7c7b51f56..ab38e76085 100644 --- a/apps/api/src/routes/v1_apis_listKeys.ts +++ b/apps/api/src/routes/v1_apis_listKeys.ts @@ -317,6 +317,7 @@ export const registerV1ApisListKeys = (app: App) => ? { interval: k.refillInterval, amount: k.refillAmount, + refillDay: k.refillInterval === "monthly" && k.refillDay ? k.refillDay : null, lastRefillAt: k.lastRefillAt?.getTime(), } : undefined, diff --git a/apps/api/src/routes/v1_keys_createKey.error.test.ts b/apps/api/src/routes/v1_keys_createKey.error.test.ts index ced18efc5d..f9a197f956 100644 --- a/apps/api/src/routes/v1_keys_createKey.error.test.ts +++ b/apps/api/src/routes/v1_keys_createKey.error.test.ts @@ -11,7 +11,6 @@ import type { V1KeysCreateKeyRequest, V1KeysCreateKeyResponse } from "./v1_keys_ test("when the api does not exist", async (t) => { const h = await IntegrationHarness.init(t); const apiId = newId("api"); - const root = await h.createRootKey([`api.${apiId}.create_key`]); /* The code snippet is making a POST request to the "/v1/keys.createKey" endpoint with the specified headers. It is using the `h.post` method from the `Harness` instance to send the request. The generic types `` specify the request payload and response types respectively. */ @@ -119,3 +118,35 @@ test("when key recovery is not enabled", async (t) => { }, }); }); + +test("reject invalid refill config when daily interval has non-null refillDay", async (t) => { + const h = await IntegrationHarness.init(t); + + const root = await h.createRootKey([`api.${h.resources.userApi.id}.create_key`]); + + const res = await h.post({ + url: "/v1/keys.createKey", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${root.key}`, + }, + body: { + byteLength: 16, + apiId: h.resources.userApi.id, + remaining: 10, + refill: { + amount: 100, + refillDay: 4, + interval: "daily", + }, + }, + }); + expect(res.status).toEqual(400); + expect(res.body).toMatchObject({ + error: { + code: "BAD_REQUEST", + docs: "https://unkey.dev/docs/api-reference/errors/code/BAD_REQUEST", + message: "when interval is set to 'daily', 'refillDay' must be null.", + }, + }); +}); diff --git a/apps/api/src/routes/v1_keys_createKey.happy.test.ts b/apps/api/src/routes/v1_keys_createKey.happy.test.ts index 8b02cec8e5..d8a8be632d 100644 --- a/apps/api/src/routes/v1_keys_createKey.happy.test.ts +++ b/apps/api/src/routes/v1_keys_createKey.happy.test.ts @@ -467,4 +467,35 @@ describe("with externalId", () => { expect(key!.identity!.id).toEqual(identity.id); }); }); + describe("Should default first day of month if none provided", () => { + test("should provide default value", async (t) => { + const h = await IntegrationHarness.init(t); + const root = await h.createRootKey([`api.${h.resources.userApi.id}.create_key`]); + + const res = await h.post({ + url: "/v1/keys.createKey", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${root.key}`, + }, + body: { + apiId: h.resources.userApi.id, + remaining: 10, + refill: { + interval: "monthly", + amount: 20, + refillDay: undefined, + }, + }, + }); + + expect(res.status, `expected 200, received: ${JSON.stringify(res, null, 2)}`).toBe(200); + + const key = await h.db.primary.query.keys.findFirst({ + where: (table, { eq }) => eq(table.id, res.body.keyId), + }); + expect(key).toBeDefined(); + expect(key!.refillDay).toEqual(1); + }); + }); }); diff --git a/apps/api/src/routes/v1_keys_createKey.ts b/apps/api/src/routes/v1_keys_createKey.ts index 4841dfb36c..3870d7d68c 100644 --- a/apps/api/src/routes/v1_keys_createKey.ts +++ b/apps/api/src/routes/v1_keys_createKey.ts @@ -118,16 +118,27 @@ When validating a key, we will return this back to you, so you can clearly ident description: "The number of verifications to refill for each occurrence is determined individually for each key.", }), + refillDay: z + .number() + .min(1) + .max(31) + .optional() + .openapi({ + description: `The day of the month, when we will refill the remaining verifications. To refill on the 15th of each month, set 'refillDay': 15. + If the day does not exist, for example you specified the 30th and it's february, we will refill them on the last day of the month instead.`, + }), }) .optional() .openapi({ description: "Unkey enables you to refill verifications for each key at regular intervals.", example: { - interval: "daily", + interval: "monthly", amount: 100, + refillDay: 15, }, }), + ratelimit: z .object({ async: z @@ -309,6 +320,12 @@ export const registerV1KeysCreateKey = (app: App) => message: "remaining must be set if you are using refill.", }); } + if (req.refill?.refillDay && req.refill.interval === "daily") { + throw new UnkeyApiError({ + code: "BAD_REQUEST", + message: "when interval is set to 'daily', 'refillDay' must be null.", + }); + } /** * Set up an api for production */ @@ -325,6 +342,7 @@ export const registerV1KeysCreateKey = (app: App) => ? upsertIdentity(db.primary, authorizedWorkspaceId, externalId) : Promise.resolve(null), ]); + const newKey = await retry(5, async (attempt) => { if (attempt > 1) { logger.warn("retrying key creation", { @@ -357,6 +375,7 @@ export const registerV1KeysCreateKey = (app: App) => ratelimitDuration: req.ratelimit?.duration ?? req.ratelimit?.refillInterval, remaining: req.remaining, refillInterval: req.refill?.interval, + refillDay: req.refill?.interval === "daily" ? null : req?.refill?.refillDay ?? 1, refillAmount: req.refill?.amount, lastRefillAt: req.refill?.interval ? new Date() : null, deletedAt: null, diff --git a/apps/api/src/routes/v1_keys_getKey.ts b/apps/api/src/routes/v1_keys_getKey.ts index a1fd3efb23..524b5b215c 100644 --- a/apps/api/src/routes/v1_keys_getKey.ts +++ b/apps/api/src/routes/v1_keys_getKey.ts @@ -155,6 +155,7 @@ export const registerV1KeysGetKey = (app: App) => ? { interval: key.refillInterval, amount: key.refillAmount, + refillDay: key.refillInterval === "monthly" ? key.refillDay : null, lastRefillAt: key.lastRefillAt?.getTime(), } : undefined, diff --git a/apps/api/src/routes/v1_keys_updateKey.error.test.ts b/apps/api/src/routes/v1_keys_updateKey.error.test.ts index b9954a3642..396b0564cd 100644 --- a/apps/api/src/routes/v1_keys_updateKey.error.test.ts +++ b/apps/api/src/routes/v1_keys_updateKey.error.test.ts @@ -3,6 +3,10 @@ import { expect, test } from "vitest"; import { newId } from "@unkey/id"; import { IntegrationHarness } from "src/pkg/testutil/integration-harness"; +import type { ErrorResponse } from "@/pkg/errors"; +import { schema } from "@unkey/db"; +import { sha256 } from "@unkey/hash"; +import { KeyV1 } from "@unkey/keys"; import type { V1KeysUpdateKeyRequest, V1KeysUpdateKeyResponse } from "./v1_keys_updateKey"; test("when the key does not exist", async (t) => { @@ -31,3 +35,46 @@ test("when the key does not exist", async (t) => { }, }); }); +test("reject invalid refill config", async (t) => { + const h = await IntegrationHarness.init(t); + const keyId = newId("test"); + const root = await h.createRootKey([`api.${h.resources.userApi.id}.update_key`]); + /* The code snippet is making a POST request to the "/v1/keys.createKey" endpoint with the specified headers. It is using the `h.post` method from the `Harness` instance to send the request. The generic types `` specify the request payload and response types respectively. */ + const key = { + id: keyId, + keyAuthId: h.resources.userKeyAuth.id, + workspaceId: h.resources.userWorkspace.id, + start: "test", + name: "test", + remaining: 10, + hash: await sha256(new KeyV1({ byteLength: 16 }).toString()), + + createdAt: new Date(), + }; + await h.db.primary.insert(schema.keys).values(key); + + const res = await h.post({ + url: "/v1/keys.updateKey", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${root.key}`, + }, + body: { + keyId, + remaining: 10, + refill: { + amount: 100, + refillDay: 4, + interval: "daily", + }, + }, + }); + expect(res.status).toEqual(400); + expect(res.body).toMatchObject({ + error: { + code: "BAD_REQUEST", + docs: "https://unkey.dev/docs/api-reference/errors/code/BAD_REQUEST", + message: "Cannot set 'refillDay' if 'interval' is 'daily'", + }, + }); +}); diff --git a/apps/api/src/routes/v1_keys_updateKey.happy.test.ts b/apps/api/src/routes/v1_keys_updateKey.happy.test.ts index 30c1d555e1..f51d034a19 100644 --- a/apps/api/src/routes/v1_keys_updateKey.happy.test.ts +++ b/apps/api/src/routes/v1_keys_updateKey.happy.test.ts @@ -1013,3 +1013,48 @@ test("update ratelimit should not disable it", async (t) => { expect(verify.body.ratelimit!.limit).toBe(5); expect(verify.body.ratelimit!.remaining).toBe(4); }); +describe("When refillDay is omitted.", () => { + test("should provide default value", async (t) => { + const h = await IntegrationHarness.init(t); + + const key = { + id: newId("test"), + keyAuthId: h.resources.userKeyAuth.id, + workspaceId: h.resources.userWorkspace.id, + start: "test", + name: "test", + remaining: 10, + hash: await sha256(new KeyV1({ byteLength: 16 }).toString()), + + createdAt: new Date(), + }; + await h.db.primary.insert(schema.keys).values(key); + const root = await h.createRootKey([`api.${h.resources.userApi.id}.update_key`]); + const res = await h.post({ + url: "/v1/keys.updateKey", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${root.key}`, + }, + body: { + keyId: key.id, + refill: { + interval: "monthly", + amount: 130, + }, + enabled: true, + }, + }); + + expect(res.status, `expected 200, received: ${JSON.stringify(res, null, 2)}`).toBe(200); + + const found = await h.db.primary.query.keys.findFirst({ + where: (table, { eq }) => eq(table.id, key.id), + }); + expect(found).toBeDefined(); + expect(found?.remaining).toEqual(10); + expect(found?.refillAmount).toEqual(130); + expect(found?.refillInterval).toEqual("monthly"); + expect(found?.refillDay).toEqual(1); + }); +}); diff --git a/apps/api/src/routes/v1_keys_updateKey.ts b/apps/api/src/routes/v1_keys_updateKey.ts index a2c90fb72d..c28c0d3e0e 100644 --- a/apps/api/src/routes/v1_keys_updateKey.ts +++ b/apps/api/src/routes/v1_keys_updateKey.ts @@ -151,6 +151,10 @@ This field will become required in a future version.`, description: "The amount of verifications to refill for each occurrence is determined individually for each key.", }), + refillDay: z.number().min(1).max(31).optional().openapi({ + description: + "The day verifications will refill each month, when interval is set to 'monthly'", + }), }) .nullable() .optional() @@ -276,9 +280,7 @@ export const registerV1KeysUpdate = (app: App) => app.openapi(route, async (c) => { const req = c.req.valid("json"); const { cache, db, usageLimiter, analytics, rbac } = c.get("services"); - const auth = await rootKeyAuth(c); - const key = await db.primary.query.keys.findFirst({ where: (table, { eq }) => eq(table.id, req.keyId), with: { @@ -329,13 +331,12 @@ export const registerV1KeysUpdate = (app: App) => message: "Cannot set refill on a key with unlimited requests", }); } - if (req.refill && key.remaining === null) { + if (req.refill?.interval === "daily" && req.refill.refillDay) { throw new UnkeyApiError({ code: "BAD_REQUEST", - message: "Cannot set refill on a key with unlimited requests", + message: "Cannot set 'refillDay' if 'interval' is 'daily'", }); } - const authorizedWorkspaceId = auth.authorizedWorkspaceId; const rootKeyId = auth.key.id; @@ -377,6 +378,7 @@ export const registerV1KeysUpdate = (app: App) => : req.ratelimit?.duration ?? req.ratelimit?.refillInterval ?? null, refillInterval: req.refill === null ? null : req.refill?.interval, refillAmount: req.refill === null ? null : req.refill?.amount, + refillDay: req.refill?.interval === "daily" ? null : req?.refill?.refillDay ?? 1, lastRefillAt: req.refill == null || req.refill?.amount == null ? null : new Date(), enabled: req.enabled, }) diff --git a/apps/api/src/routes/v1_migrations_createKey.error.test.ts b/apps/api/src/routes/v1_migrations_createKey.error.test.ts index 45523eaec5..14562b30e1 100644 --- a/apps/api/src/routes/v1_migrations_createKey.error.test.ts +++ b/apps/api/src/routes/v1_migrations_createKey.error.test.ts @@ -112,3 +112,39 @@ test("reject invalid ratelimit config", async (t) => { expect(res.status).toEqual(400); expect(res.body.error.code).toEqual("BAD_REQUEST"); }); +test("reject invalid refill config when daily interval has non-null refillDay", async (t) => { + const h = await IntegrationHarness.init(t); + const { key } = await h.createRootKey(["*"]); + + const res = await h.post({ + url: "/v1/migrations.createKeys", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${key}`, + }, + body: [ + { + start: "x", + hash: { + value: "x", + variant: "sha256_base64", + }, + apiId: h.resources.userApi.id, + remaining: 10, + refill: { + amount: 100, + refillDay: 4, + interval: "daily", + }, + }, + ], + }); + expect(res.status).toEqual(400); + expect(res.body).toMatchObject({ + error: { + code: "BAD_REQUEST", + docs: "https://unkey.dev/docs/api-reference/errors/code/BAD_REQUEST", + message: "when interval is set to 'daily', 'refillDay' must be null.", + }, + }); +}); diff --git a/apps/api/src/routes/v1_migrations_createKey.happy.test.ts b/apps/api/src/routes/v1_migrations_createKey.happy.test.ts index 6fe8f27b48..6e633b90a3 100644 --- a/apps/api/src/routes/v1_migrations_createKey.happy.test.ts +++ b/apps/api/src/routes/v1_migrations_createKey.happy.test.ts @@ -496,3 +496,48 @@ test("migrate and verify a key", async (t) => { expect(verifyRes.status).toBe(200); expect(verifyRes.body.valid).toEqual(true); }); + +describe("Should default to first day of month if none provided", () => { + test("should provide default value", async (t) => { + const h = await IntegrationHarness.init(t); + const root = await h.createRootKey([`api.${h.resources.userApi.id}.create_key`]); + + const hash = await sha256(randomUUID()); + const res = await h.post({ + url: "/v1/migrations.createKeys", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${root.key}`, + }, + body: [ + { + start: "start_", + hash: { + value: hash, + variant: "sha256_base64", + }, + apiId: h.resources.userApi.id, + enabled: true, + remaining: 10, + refill: { + interval: "monthly", + amount: 100, + refillDay: undefined, + }, + }, + ], + }); + + expect(res.status, `expected 200, received: ${JSON.stringify(res, null, 2)}`).toBe(200); + + const found = await h.db.primary.query.keys.findFirst({ + where: (table, { eq }) => eq(table.id, res.body.keyIds[0]), + }); + expect(found).toBeDefined(); + expect(found?.remaining).toEqual(10); + expect(found?.refillAmount).toEqual(100); + expect(found?.refillInterval).toEqual("monthly"); + expect(found?.refillDay).toEqual(1); + expect(found?.hash).toEqual(hash); + }); +}); diff --git a/apps/api/src/routes/v1_migrations_createKey.ts b/apps/api/src/routes/v1_migrations_createKey.ts index 50914bc9d0..311c54fb1c 100644 --- a/apps/api/src/routes/v1_migrations_createKey.ts +++ b/apps/api/src/routes/v1_migrations_createKey.ts @@ -131,6 +131,10 @@ When validating a key, we will return this back to you, so you can clearly ident description: "The number of verifications to refill for each occurrence is determined individually for each key.", }), + refillDay: z.number().min(1).max(31).optional().openapi({ + description: + "The day verifications will refill each month, when interval is set to 'monthly'", + }), }) .optional() .openapi({ @@ -388,6 +392,12 @@ export const registerV1MigrationsCreateKeys = (app: App) => message: "provide either `hash` or `plaintext`", }); } + if (key.refill?.refillDay && key.refill.interval === "daily") { + throw new UnkeyApiError({ + code: "BAD_REQUEST", + message: "when interval is set to 'daily', 'refillDay' must be null.", + }); + } /** * Set up an api for production */ @@ -412,14 +422,15 @@ export const registerV1MigrationsCreateKeys = (app: App) => ratelimitDuration: key.ratelimit?.refillInterval ?? key.ratelimit?.refillInterval ?? null, remaining: key.remaining ?? null, refillInterval: key.refill?.interval ?? null, + refillDay: key.refill?.interval === "daily" ? null : key?.refill?.refillDay ?? 1, refillAmount: key.refill?.amount ?? null, - lastRefillAt: key.refill?.interval ? new Date() : null, deletedAt: null, enabled: key.enabled ?? true, environment: key.environment ?? null, createdAtM: Date.now(), updatedAtM: null, deletedAtM: null, + lastRefillAt: null, }); for (const role of key.roles ?? []) { diff --git a/apps/api/src/routes/v1_migrations_enqueueKeys.ts b/apps/api/src/routes/v1_migrations_enqueueKeys.ts index efecfe80cc..a21d6931ea 100644 --- a/apps/api/src/routes/v1_migrations_enqueueKeys.ts +++ b/apps/api/src/routes/v1_migrations_enqueueKeys.ts @@ -131,6 +131,10 @@ When validating a key, we will return this back to you, so you can clearly ident description: "The number of verifications to refill for each occurrence is determined individually for each key.", }), + refillDay: z.number().min(1).max(31).optional().openapi({ + description: + "The day verifications will refill each month, when interval is set to 'monthly'", + }), }) .optional() .openapi({ diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/update-key-remaining.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/update-key-remaining.tsx index 0a9d978f65..beee505036 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/update-key-remaining.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/update-key-remaining.tsx @@ -51,6 +51,19 @@ const formSchema = z.object({ }) .positive() .optional(), + refillDay: z.coerce + .number({ + errorMap: (issue, { defaultError }) => ({ + message: + issue.code === "invalid_type" + ? "Refill day must be an integer between 1 and 31" + : defaultError, + }), + }) + .int() + .min(1) + .max(31) + .optional(), }) .optional(), }); @@ -61,6 +74,7 @@ type Props = { remaining: number | null; refillInterval: "daily" | "monthly" | null; refillAmount: number | null; + refillDay: number | null; }; }; @@ -78,6 +92,8 @@ export const UpdateKeyRemaining: React.FC = ({ apiKey }) => { refill: { interval: apiKey.refillInterval === null ? "none" : apiKey.refillInterval, amount: apiKey.refillAmount ? apiKey.refillAmount : undefined, + refillDay: + apiKey.refillInterval === "monthly" && apiKey.refillDay ? apiKey.refillDay : undefined, }, }, }); @@ -213,6 +229,27 @@ export const UpdateKeyRemaining: React.FC = ({ apiKey }) => { )} /> + ( + + Day of the month to refill uses + + + + Enter the day to refill monthly. + + + )} + /> diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/new/client.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/new/client.tsx index b63ffd734e..f940d81724 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/new/client.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/new/client.tsx @@ -25,6 +25,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; + import { Separator } from "@/components/ui/separator"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; @@ -90,11 +91,10 @@ const formSchema = z.object({ }), }) .int() - .positive({ message: "Please enter a positive number" }) - .optional(), + .positive({ message: "Please enter a positive number" }), refill: z .object({ - interval: z.enum(["none", "daily", "monthly"]).default("none"), + interval: z.enum(["daily", "monthly"]).default("monthly"), amount: z.coerce .number({ errorMap: (issue, { defaultError }) => ({ @@ -106,7 +106,19 @@ const formSchema = z.object({ }) .int() .min(1) - .positive() + .positive(), + refillDay: z.coerce + .number({ + errorMap: (issue, { defaultError }) => ({ + message: + issue.code === "invalid_type" + ? "Refill day must be an integer between 1 and 31" + : defaultError, + }), + }) + .int() + .min(1) + .max(31) .optional(), }) .optional(), @@ -151,7 +163,6 @@ type Props = { export const CreateKey: React.FC = ({ apiId, keyAuthId, defaultBytes, defaultPrefix }) => { const router = useRouter(); - const form = useForm>({ resolver: async (data, context, options) => { return zodResolver(formSchema)(data, context, options); @@ -196,6 +207,13 @@ export const CreateKey: React.FC = ({ apiId, keyAuthId, defaultBytes, def if (!values.ratelimitEnabled) { delete values.ratelimit; } + const refill = values.limit?.refill; + if (refill?.interval === "daily") { + refill?.refillDay === undefined; + } + if (refill?.interval === "monthly" && !refill.refillDay) { + refill.refillDay = 1; + } await key.mutateAsync({ keyAuthId, @@ -204,6 +222,7 @@ export const CreateKey: React.FC = ({ apiId, keyAuthId, defaultBytes, def expires: values.expires?.getTime() ?? undefined, ownerId: values.ownerId ?? undefined, remaining: values.limit?.remaining ?? undefined, + refill: refill, enabled: true, }); @@ -434,9 +453,7 @@ export const CreateKey: React.FC = ({ apiId, keyAuthId, defaultBytes, def )} /> - -
@@ -527,12 +544,10 @@ export const CreateKey: React.FC = ({ apiId, keyAuthId, defaultBytes, def ) : null} -
Limited Use - = ({ apiId, keyAuthId, defaultBytes, def Refill Rate - + + Interval key will be refilled. + )} /> ( @@ -637,6 +649,39 @@ export const CreateKey: React.FC = ({ apiId, keyAuthId, defaultBytes, def )} /> + + ( + + {/* Refill day or daily */} + +
+ +
+
+ + Enter the day to refill monthly. + + +
+ )} + /> How many requests may be performed in a given interval @@ -657,6 +702,7 @@ export const CreateKey: React.FC = ({ apiId, keyAuthId, defaultBytes, def ( @@ -816,9 +862,9 @@ export const CreateKey: React.FC = ({ apiId, keyAuthId, defaultBytes, def
diff --git a/apps/dashboard/app/(app)/settings/root-keys/[keyId]/permissions/permissions.ts b/apps/dashboard/app/(app)/settings/root-keys/[keyId]/permissions/permissions.ts index 7f0e049bda..c0964b033a 100644 --- a/apps/dashboard/app/(app)/settings/root-keys/[keyId]/permissions/permissions.ts +++ b/apps/dashboard/app/(app)/settings/root-keys/[keyId]/permissions/permissions.ts @@ -1,12 +1,10 @@ import type { UnkeyPermission } from "@unkey/rbac"; - export type UnkeyPermissions = { [action: string]: { description: string; permission: UnkeyPermission; }; }; - export const workspacePermissions = { API: { create_api: { @@ -135,7 +133,6 @@ export const workspacePermissions = { }, }, } satisfies Record; - export function apiPermissions(apiId: string): { [category: string]: UnkeyPermissions } { return { API: { diff --git a/apps/dashboard/lib/trpc/routers/key/create.ts b/apps/dashboard/lib/trpc/routers/key/create.ts index 46f2b0e6fc..78c2516df7 100644 --- a/apps/dashboard/lib/trpc/routers/key/create.ts +++ b/apps/dashboard/lib/trpc/routers/key/create.ts @@ -19,6 +19,7 @@ export const createKey = rateLimitedProcedure(ratelimit.create) .object({ interval: z.enum(["daily", "monthly"]), amount: z.coerce.number().int().min(1), + refillDay: z.number().int().min(1).max(31).optional(), }) .optional(), expires: z.number().int().nullish(), // unix timestamp in milliseconds @@ -101,6 +102,7 @@ export const createKey = rateLimitedProcedure(ratelimit.create) ratelimitDuration: input.ratelimit?.duration, remaining: input.remaining, refillInterval: input.refill?.interval ?? null, + refillDay: input.refill?.refillDay ?? null, refillAmount: input.refill?.amount ?? null, lastRefillAt: input.refill?.interval ? new Date() : null, deletedAt: null, diff --git a/apps/dashboard/lib/trpc/routers/key/createRootKey.ts b/apps/dashboard/lib/trpc/routers/key/createRootKey.ts index c78fafd35c..c8d4e0e10b 100644 --- a/apps/dashboard/lib/trpc/routers/key/createRootKey.ts +++ b/apps/dashboard/lib/trpc/routers/key/createRootKey.ts @@ -92,6 +92,7 @@ export const createRootKey = rateLimitedProcedure(ratelimit.create) remaining: null, refillInterval: null, refillAmount: null, + refillDay: null, lastRefillAt: null, deletedAt: null, enabled: true, diff --git a/apps/dashboard/lib/trpc/routers/key/updateRemaining.ts b/apps/dashboard/lib/trpc/routers/key/updateRemaining.ts index 4e04452366..6225b3241b 100644 --- a/apps/dashboard/lib/trpc/routers/key/updateRemaining.ts +++ b/apps/dashboard/lib/trpc/routers/key/updateRemaining.ts @@ -14,6 +14,7 @@ export const updateKeyRemaining = rateLimitedProcedure(ratelimit.update) .object({ interval: z.enum(["daily", "monthly", "none"]), amount: z.number().int().min(1).optional(), + refillDay: z.number().int().min(1).max(31).optional(), }) .optional(), }), @@ -36,7 +37,7 @@ export const updateKeyRemaining = rateLimitedProcedure(ratelimit.update) workspace: true, }, }); - + const isMonthlyInterval = input.refill?.interval === "monthly"; if (!key || key.workspace.tenantId !== ctx.tenant.id) { throw new TRPCError({ message: @@ -52,6 +53,7 @@ export const updateKeyRemaining = rateLimitedProcedure(ratelimit.update) input.refill?.interval === "none" || input.refill?.interval === undefined ? null : input.refill?.interval, + refillDay: isMonthlyInterval ? input.refill?.refillDay : null, refillAmount: input.refill?.amount ?? null, lastRefillAt: input.refill?.interval ? new Date() : null, }) diff --git a/apps/dashboard/lib/zod-helper.ts b/apps/dashboard/lib/zod-helper.ts index 0d08868d4a..7970b612ec 100644 --- a/apps/dashboard/lib/zod-helper.ts +++ b/apps/dashboard/lib/zod-helper.ts @@ -44,6 +44,19 @@ export const formSchema = z.object({ .int() .min(1) .positive(), + refillDay: z.coerce + .number({ + errorMap: (issue, { defaultError }) => ({ + message: + issue.code === "invalid_type" + ? "Refill day must be an integer between 1 and 31" + : defaultError, + }), + }) + .int() + .min(1) + .max(31) + .optional(), }) .optional(), }) diff --git a/apps/docs/libraries/ts/sdk/keys/create.mdx b/apps/docs/libraries/ts/sdk/keys/create.mdx index b5c9671df4..5c212b180d 100644 --- a/apps/docs/libraries/ts/sdk/keys/create.mdx +++ b/apps/docs/libraries/ts/sdk/keys/create.mdx @@ -108,6 +108,13 @@ Unkey allows automatic refill on 'remaining' on a 'daily' or 'monthly' interval. The amount to refill 'remaining'. +Read more [here](/apis/features/refill) + + + value from `1` to `31`. + + The day each month to refill 'remaining'. If no value is given, The 1st will be used as a default. + Read more [here](/apis/features/refill) @@ -152,8 +159,9 @@ const created = await unkey.keys.create({ }, remaining: 1000, refill: { - interval: "daily", - amount: 100 + interval: "monthly", + amount: 100, + refillDay: 15, }, enabled: true }) diff --git a/apps/docs/libraries/ts/sdk/keys/get.mdx b/apps/docs/libraries/ts/sdk/keys/get.mdx index c36c113d58..17c3724248 100644 --- a/apps/docs/libraries/ts/sdk/keys/get.mdx +++ b/apps/docs/libraries/ts/sdk/keys/get.mdx @@ -59,13 +59,23 @@ The number of requests that can be made with this key before it becomes invalid. Unkey allows you to refill remaining verifications on a key on a regular interval. - + Determines the rate at which verifications will be refilled. + +Available options: +- `daily`: Refills occur every day +- `monthly`: Refills occur once a month (see `refillDay` for specific day) Resets `remaining` to this value every interval. + + value from `1` to `31`. + + The day each month to refill 'remaining'. If no value is given, The 1st will be used as a default. +Read more [here](/apis/features/refill) + The unix timestamp in miliseconds when the key was last refilled. diff --git a/apps/docs/libraries/ts/sdk/keys/update.mdx b/apps/docs/libraries/ts/sdk/keys/update.mdx index 832db7cbc3..447cd7b48e 100644 --- a/apps/docs/libraries/ts/sdk/keys/update.mdx +++ b/apps/docs/libraries/ts/sdk/keys/update.mdx @@ -79,6 +79,13 @@ Unkey allows automatic refill on 'remaining' on a 'daily' or 'monthly' interval. The amount to refill 'remaining'. +Read more [here](/apis/features/refill) + + + value from `1` to `31`. + + The day each month to refill 'remaining'. If no value is given, The 1st will be used as a default. + Read more [here](/apis/features/refill) @@ -110,8 +117,9 @@ await unkey.keys.update({ ownerId: "new owner", remaining: 300, refil: { - interval: "daily", - amount: 100 + interval: "monthly", + amount: 100, + refillDay: 15, }, enabled: true }); diff --git a/apps/semantic-cache/package.json b/apps/semantic-cache/package.json index 8d9418351c..5c9b027d3e 100644 --- a/apps/semantic-cache/package.json +++ b/apps/semantic-cache/package.json @@ -27,7 +27,7 @@ }, "dependencies": { "@chronark/zod-bird": "^0.3.9", - "@planetscale/database": "^1.18.0", + "@planetscale/database": "^1.16.0", "@unkey/cache": "workspace:^", "@unkey/db": "workspace:^", "@unkey/error": "workspace:^", diff --git a/apps/workflows/jobs/index.ts b/apps/workflows/jobs/index.ts index 249e51f0fa..4b574e1e08 100644 --- a/apps/workflows/jobs/index.ts +++ b/apps/workflows/jobs/index.ts @@ -1,4 +1,3 @@ // export all your job files here export * from "./refill-daily"; -export * from "./refill-monthly"; diff --git a/apps/workflows/jobs/refill-daily.ts b/apps/workflows/jobs/refill-daily.ts index eca82f7578..4800b88115 100644 --- a/apps/workflows/jobs/refill-daily.ts +++ b/apps/workflows/jobs/refill-daily.ts @@ -1,4 +1,4 @@ -import { connectDatabase, eq, lte, schema } from "@/lib/db"; +import { connectDatabase, eq, schema } from "@/lib/db"; import { client } from "@/trigger"; import { cronTrigger } from "@trigger.dev/sdk"; import { newId } from "@unkey/id"; @@ -11,33 +11,36 @@ client.defineJob({ cron: "0 0 * * *", // Daily at midnight UTC }), - run: async (_payload, io, _ctx) => { + run: async (payload, io, _ctx) => { + const date = payload.ts; + // Set up last day of month so if refillDay is after last day of month, Key will be refilled today. + const lastDayOfMonth = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate(); + const today = date.getUTCDate(); const db = connectDatabase(); - const t = new Date(); - t.setUTCHours(t.getUTCHours() - 24); const BUCKET_NAME = "unkey_mutations"; type Key = `${string}::${string}`; type BucketId = string; const bucketCache = new Map(); - const keys = await io.runTask("list keys", () => - db.query.keys.findMany({ - where: (table, { isNotNull, isNull, eq, and, gt, or }) => - and( - isNull(table.deletedAt), - isNotNull(table.refillInterval), - isNotNull(table.refillAmount), - eq(table.refillInterval, "daily"), - gt(table.refillAmount, table.remaining), - or( - isNull(table.lastRefillAt), - lte(table.lastRefillAt, t), // Check if more than 24 hours have passed - ), - ), - }), - ); - io.logger.info(`found ${keys.length} keys with daily refill set`); + // If refillDay is after last day of month, refillDay will be today. + const keys = await db.query.keys.findMany({ + where: (table, { isNotNull, isNull, and, gt, or, eq }) => { + const baseConditions = and( + isNull(table.deletedAt), + isNotNull(table.refillAmount), + gt(table.refillAmount, table.remaining), + or(isNull(table.refillDay), eq(table.refillDay, today)), + ); + + if (today === lastDayOfMonth) { + return and(baseConditions, gt(table.refillDay, today)); + } + return baseConditions; + }, + }); + + io.logger.info(`found ${keys.length} keys with refill set for today`); for (const key of keys) { const cacheKey: Key = `${key.workspaceId}::${BUCKET_NAME}`; let bucketId = ""; @@ -109,7 +112,7 @@ client.defineJob({ }); } return { - keyIds: keys.map((k) => k.id), + refillKeyIds: keys.map((k) => k.id), }; }, }); diff --git a/apps/workflows/jobs/refill-monthly.ts b/apps/workflows/jobs/refill-monthly.ts deleted file mode 100644 index 47c9cd1369..0000000000 --- a/apps/workflows/jobs/refill-monthly.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { connectDatabase, eq, gt, lte, schema } from "@/lib/db"; -import { client } from "@/trigger"; -import { cronTrigger } from "@trigger.dev/sdk"; -import { newId } from "@unkey/id"; -client.defineJob({ - id: "refill.monthly", - name: "Monthly refill", - version: "0.0.1", - trigger: cronTrigger({ - cron: "0 0 0 1 1/1 ? *", // First of each month at 00:00 UTC - }), - - run: async (_payload, io, _ctx) => { - const db = connectDatabase(); - const t = new Date(); - t.setUTCMonth(t.getUTCMonth() - 1); - const BUCKET_NAME = "unkey_mutations"; - - type Key = `${string}::${string}`; - type BucketId = string; - const bucketCache = new Map(); - const keys = await io.runTask("list keys", () => - db.query.keys.findMany({ - where: (table, { isNotNull, isNull, eq, and, or }) => - and( - isNull(table.deletedAt), - isNotNull(table.refillInterval), - isNotNull(table.refillAmount), - eq(table.refillInterval, "monthly"), - gt(table.refillAmount, table.remaining), - or( - isNull(table.lastRefillAt), - lte(table.lastRefillAt, t), // Check if more than 1 Month has passed - ), - ), - }), - ); - io.logger.info(`found ${keys.length} keys with monthly refill set`); - for (const key of keys) { - const cacheKey: Key = `${key.workspaceId}::${BUCKET_NAME}`; - let bucketId = ""; - const cachedBucketId = bucketCache.get(cacheKey); - if (cachedBucketId) { - bucketId = cachedBucketId; - } else { - const bucket = await db.query.auditLogBucket.findFirst({ - where: (table, { eq, and }) => - and(eq(table.workspaceId, key.workspaceId), eq(table.name, BUCKET_NAME)), - columns: { - id: true, - }, - }); - - if (bucket) { - bucketId = bucket.id; - } else { - bucketId = newId("auditLogBucket"); - await db.insert(schema.auditLogBucket).values({ - id: bucketId, - workspaceId: key.workspaceId, - name: BUCKET_NAME, - }); - } - } - - bucketCache.set(cacheKey, bucketId); - await io.runTask(`refill for ${key.id}`, async () => { - await db.transaction(async (tx) => { - await tx - .update(schema.keys) - .set({ - remaining: key.refillAmount, - lastRefillAt: new Date(), - }) - .where(eq(schema.keys.id, key.id)); - - const auditLogId = newId("auditLog"); - await tx.insert(schema.auditLog).values({ - id: auditLogId, - workspaceId: key.workspaceId, - bucketId: bucketId, - time: Date.now(), - event: "key.update", - actorId: "trigger", - actorType: "system", - display: `Refilled ${key.id} to ${key.refillAmount}`, - }); - await tx.insert(schema.auditLogTarget).values([ - { - type: "workspace", - id: key.workspaceId, - workspaceId: key.workspaceId, - bucketId: bucketId, - auditLogId, - displayName: `workspace ${key.workspaceId}`, - }, - { - type: "key", - id: key.id, - workspaceId: key.workspaceId, - bucketId: bucketId, - auditLogId, - displayName: `key ${key.id}`, - }, - ]); - }); - }); - } - return { - keyIds: keys.map((k) => k.id), - }; - }, -}); diff --git a/internal/db/src/schema/key_migrations.ts b/internal/db/src/schema/key_migrations.ts index 3d12548dc5..0c3470435b 100644 --- a/internal/db/src/schema/key_migrations.ts +++ b/internal/db/src/schema/key_migrations.ts @@ -30,7 +30,7 @@ export const keyMigrationErrors = mysqlTable("key_migration_errors", { permissions?: string[]; expires?: number; remaining?: number; - refill?: { interval: "daily" | "monthly"; amount: number }; + refill?: { interval: "daily" | "monthly"; amount: number; refillDay?: number | undefined }; ratelimit?: { async: boolean; limit: number; duration: number }; enabled: boolean; environment?: string; diff --git a/internal/db/src/schema/keys.ts b/internal/db/src/schema/keys.ts index 5eeb701f65..afba30984f 100644 --- a/internal/db/src/schema/keys.ts +++ b/internal/db/src/schema/keys.ts @@ -8,6 +8,7 @@ import { mysqlEnum, mysqlTable, text, + tinyint, uniqueIndex, varchar, } from "drizzle-orm/mysql-core"; @@ -63,6 +64,7 @@ export const keys = mysqlTable( * You can refill uses to keys at a desired interval */ refillInterval: mysqlEnum("refill_interval", ["daily", "monthly"]), + refillDay: tinyint("refill_day"), refillAmount: int("refill_amount"), lastRefillAt: datetime("last_refill_at", { fsp: 3 }), /** diff --git a/packages/rbac/src/permissions.ts b/packages/rbac/src/permissions.ts index 0f7e9b7f08..e387617183 100644 --- a/packages/rbac/src/permissions.ts +++ b/packages/rbac/src/permissions.ts @@ -7,10 +7,8 @@ * - `gateway_id.xxx` * */ - import { z } from "zod"; import type { Flatten } from "./types"; - export function buildIdSchema(prefix: string) { return z.string().refine((s) => { if (s === "*") { @@ -26,7 +24,6 @@ const apiId = buildIdSchema("api"); const ratelimitNamespaceId = buildIdSchema("rl"); const rbacId = buildIdSchema("rbac"); const identityEnvId = z.string(); - export const apiActions = z.enum([ "read_api", "create_api", @@ -39,15 +36,16 @@ export const apiActions = z.enum([ "decrypt_key", "read_key", ]); - export const ratelimitActions = z.enum([ "limit", "create_namespace", "read_namespace", "update_namespace", "delete_namespace", + "set_override", + "read_override", + "delete_override", ]); - export const rbacActions = z.enum([ "create_permission", "update_permission", @@ -64,14 +62,12 @@ export const rbacActions = z.enum([ "add_permission_to_role", "remove_permission_from_role", ]); - export const identityActions = z.enum([ "create_identity", "read_identity", "update_identity", "delete_identity", ]); - export type Resources = { [resourceId in `api.${z.infer}`]: z.infer; } & { @@ -83,9 +79,7 @@ export type Resources = { } & { [resourceId in `identity.${z.infer}`]: z.infer; }; - export type UnkeyPermission = Flatten | "*"; - /** * Validation for roles used for our root keys */ @@ -117,7 +111,6 @@ export const unkeyPermissionValidation = z.custom().refine((s) case "identity": { return identityEnvId.safeParse(id).success && identityActions.safeParse(action).success; } - default: { return false; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55231cbbca..689ca7e82d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -832,7 +832,7 @@ importers: specifier: ^0.3.9 version: 0.3.9 '@planetscale/database': - specifier: ^1.18.0 + specifier: ^1.16.0 version: 1.18.0 '@unkey/cache': specifier: workspace:^ @@ -6946,7 +6946,7 @@ packages: resolution: {integrity: sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==} engines: {node: '>=8.0.0'} dependencies: - tslib: 2.7.0 + tslib: 2.4.1 dev: false /@peculiar/webcrypto@1.4.1: @@ -13672,7 +13672,7 @@ packages: resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} dependencies: no-case: 3.0.4 - tslib: 2.7.0 + tslib: 2.4.1 dev: false /dot-prop@6.0.1: @@ -15042,7 +15042,7 @@ packages: engines: {node: '>= 10.17.0'} hasBin: true dependencies: - debug: 4.3.7(supports-color@8.1.1) + debug: 4.3.4 get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -17638,7 +17638,7 @@ packages: /lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} dependencies: - tslib: 2.7.0 + tslib: 2.4.1 dev: false /lowercase-keys@3.0.0: @@ -19053,7 +19053,6 @@ packages: /minipass@6.0.2: resolution: {integrity: sha512-MzWSV5nYVT7mVyWCwn2o7JH13w2TBRmmSqSRCKzTw+lmft9X4z+3wjvs06Tzijo5z4W/kahUCDpRXTF+ZrmF/w==} engines: {node: '>=16 || 14 >=14.17'} - dev: true /minipass@7.1.2: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} @@ -19445,7 +19444,7 @@ packages: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} dependencies: lower-case: 2.0.2 - tslib: 2.7.0 + tslib: 2.4.1 dev: false /node-addon-api@7.1.1: @@ -20158,7 +20157,7 @@ packages: engines: {node: '>=16 || 14 >=14.18'} dependencies: lru-cache: 10.4.3 - minipass: 7.1.2 + minipass: 6.0.2 /path-to-regexp@0.1.10: resolution: {integrity: sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==} @@ -22136,7 +22135,7 @@ packages: resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} dependencies: dot-case: 3.0.4 - tslib: 2.7.0 + tslib: 2.4.1 dev: false /snakecase-keys@3.2.1: