From 0da4d7b85f100d22bdbe8ba91a52369459cf22a6 Mon Sep 17 00:00:00 2001 From: Harsh Shrikant Bhat <90265455+harshsbhat@users.noreply.github.com> Date: Thu, 3 Oct 2024 14:52:27 +0530 Subject: [PATCH 01/11] wip: Who Am I route --- apps/api/src/routes/v1_keys_getWhoAmI.ts | 173 +++++++++++++++++++++++ apps/api/src/worker.ts | 2 + 2 files changed, 175 insertions(+) create mode 100644 apps/api/src/routes/v1_keys_getWhoAmI.ts diff --git a/apps/api/src/routes/v1_keys_getWhoAmI.ts b/apps/api/src/routes/v1_keys_getWhoAmI.ts new file mode 100644 index 0000000000..6dda1fe55f --- /dev/null +++ b/apps/api/src/routes/v1_keys_getWhoAmI.ts @@ -0,0 +1,173 @@ +import type { App } from "@/pkg/hono/app"; +import { createRoute, z } from "@hono/zod-openapi"; + +import { rootKeyAuth } from "@/pkg/auth/root_key"; +import { UnkeyApiError, openApiErrorResponses } from "@/pkg/errors"; +import { buildUnkeyQuery } from "@unkey/rbac"; +import { sha256 } from "@unkey/hash"; + +const route = createRoute({ + tags: ["keys"], + operationId: "whoAmI", + method: "get", + path: "/v1/keys.whoAmI", + security: [{ bearerAuth: [] }], + request: { + query: z.object({ + key: z.string().min(1).openapi({ + description: "The actual key to fetch", + example: "actual_key_value_here", + }), + }), + }, + responses: { + 200: { + description: "The configuration for a single key", + content: { + "application/json": { + schema: z.object({ + id: z.string().openapi({ + description: "The ID of the key", + example: "abc123", + }), + name: z.string().optional().openapi({ + description: "The name of the key", + example: "API Key 1", + }), + remaining: z.number().int().optional().openapi({ + description: "The remaining number of requests for the key", + example: 1000, + }), + identity: z + .object({ + id: z.string().openapi({ + description: "The identity ID associated with the key", + example: "identity123", + }), + externalId: z.string().openapi({ + description: "The external identity ID associated with the key", + example: "ext123", + }), + }) + .optional() + .openapi({ + description: "The identity object associated with the key", + }), + meta: z + .object({}) + .optional() + .openapi({ + description: "Metadata associated with the key", + example: { role: "admin", plan: "premium" }, + }), + createdAt: z.number().int().openapi({ + description: "The timestamp when the key was created", + example: 1620000000000, + }), + enabled: z.boolean().openapi({ + description: "Whether the key is enabled", + example: true, + }), + environment: z.string().optional().openapi({ + description: "The environment the key is associated with", + example: "production", + }), + }), + }, + }, + }, + ...openApiErrorResponses, + }, +}); + +export type Route = typeof route; +export type V1KeysGetKeyResponse = z.infer< + (typeof route.responses)[200]["content"]["application/json"]["schema"] +>; + +export const registerV1KeysWhoAmi = (app: App) => + app.openapi(route, async (c) => { + const { key: secret } = c.req.valid("query"); + const { cache, db } = c.get("services"); + const hash = await sha256(secret); + const { val: data, err } = await cache.keyByHash.swr(hash, async () => { + const dbRes = await db.readonly.query.keys.findFirst({ + where: (table, { eq, and, isNull }) => and(eq(table.hash, hash), isNull(table.deletedAt)), + with: { + encrypted: true, + keyAuth: { + with: { + api: true, + }, + }, + identity: true, + }, + }); + + if (!dbRes) { + return null; + } + + return { + key: { + ...dbRes, + encrypted: dbRes.encrypted ?? null, + }, + api: dbRes.keyAuth.api, + identity: dbRes.identity + ? { + id: dbRes.identity.id, + externalId: dbRes.identity.externalId, + meta: dbRes.identity.meta ?? {}, + } + : null, + } as any; // this was neccessary so that we don't need to return the workspace and other types defined in keyByHash + }); + + + if (err) { + throw new UnkeyApiError({ + code: "INTERNAL_SERVER_ERROR", + message: `unable to load key: ${err.message}`, + }); + } + if (!data) { + throw new UnkeyApiError({ + code: "NOT_FOUND", + message: `key ${secret} not found`, + }); + } + const { api, key } = data; + const auth = await rootKeyAuth( + c, + buildUnkeyQuery(({ or }) => or("*", "api.*.read_key", `api.${api.id}.read_key`)), + ); + + if (key.workspaceId !== auth.authorizedWorkspaceId) { + throw new UnkeyApiError({ + code: "NOT_FOUND", + message: `key ${secret} not found`, + }); + } + let meta = key.meta ? JSON.parse(key.meta) : undefined; + if (!meta || Object.keys(meta).length === 0) { + meta = undefined; + } + + + return c.json({ + id: key.id, + name: key.name ?? undefined, + remaining: key.remaining ?? undefined, + identity: data.identity + ? { + id: data.identity.id, + externalId: data.identity.externalId, + } + : undefined, + meta: key.meta ? JSON.parse(key.meta) : undefined, + createdAt: key.createdAt.getTime(), + enabled: key.enabled, + environment: key.environment ?? undefined, + }); + }); \ No newline at end of file diff --git a/apps/api/src/worker.ts b/apps/api/src/worker.ts index 6a15a707f6..a21b419f11 100644 --- a/apps/api/src/worker.ts +++ b/apps/api/src/worker.ts @@ -9,6 +9,7 @@ import { registerV1ApisListKeys } from "./routes/v1_apis_listKeys"; import { registerV1KeysCreateKey } from "./routes/v1_keys_createKey"; import { registerV1KeysDeleteKey } from "./routes/v1_keys_deleteKey"; import { registerV1KeysGetKey } from "./routes/v1_keys_getKey"; +import { registerV1KeysWhoAmi } from "./routes/v1_keys_getWhoAmI"; import { registerV1KeysGetVerifications } from "./routes/v1_keys_getVerifications"; import { registerV1KeysUpdate } from "./routes/v1_keys_updateKey"; import { registerV1KeysUpdateRemaining } from "./routes/v1_keys_updateRemaining"; @@ -67,6 +68,7 @@ registerV1Liveness(app); // keys registerV1KeysGetKey(app); +registerV1KeysWhoAmi(app); registerV1KeysDeleteKey(app); registerV1KeysCreateKey(app); registerV1KeysVerifyKey(app); From 971abe2e9d913794e478e09967b2c9a27645c344 Mon Sep 17 00:00:00 2001 From: Harsh Shrikant Bhat <90265455+harshsbhat@users.noreply.github.com> Date: Fri, 4 Oct 2024 22:31:26 +0530 Subject: [PATCH 02/11] Resolved changes --- apps/api/src/routes/v1_keys_getWhoAmI.ts | 173 ------------------ .../src/routes/v1_keys_whoAmI.error.test.ts | 0 .../src/routes/v1_keys_whoAmI.happy.test.ts | 0 .../routes/v1_keys_whoAmI.security.test.ts | 0 apps/api/src/routes/v1_keys_whoAmI.ts | 171 +++++++++++++++++ apps/api/src/worker.ts | 2 +- 6 files changed, 172 insertions(+), 174 deletions(-) delete mode 100644 apps/api/src/routes/v1_keys_getWhoAmI.ts create mode 100644 apps/api/src/routes/v1_keys_whoAmI.error.test.ts create mode 100644 apps/api/src/routes/v1_keys_whoAmI.happy.test.ts create mode 100644 apps/api/src/routes/v1_keys_whoAmI.security.test.ts create mode 100644 apps/api/src/routes/v1_keys_whoAmI.ts diff --git a/apps/api/src/routes/v1_keys_getWhoAmI.ts b/apps/api/src/routes/v1_keys_getWhoAmI.ts deleted file mode 100644 index 6dda1fe55f..0000000000 --- a/apps/api/src/routes/v1_keys_getWhoAmI.ts +++ /dev/null @@ -1,173 +0,0 @@ -import type { App } from "@/pkg/hono/app"; -import { createRoute, z } from "@hono/zod-openapi"; - -import { rootKeyAuth } from "@/pkg/auth/root_key"; -import { UnkeyApiError, openApiErrorResponses } from "@/pkg/errors"; -import { buildUnkeyQuery } from "@unkey/rbac"; -import { sha256 } from "@unkey/hash"; - -const route = createRoute({ - tags: ["keys"], - operationId: "whoAmI", - method: "get", - path: "/v1/keys.whoAmI", - security: [{ bearerAuth: [] }], - request: { - query: z.object({ - key: z.string().min(1).openapi({ - description: "The actual key to fetch", - example: "actual_key_value_here", - }), - }), - }, - responses: { - 200: { - description: "The configuration for a single key", - content: { - "application/json": { - schema: z.object({ - id: z.string().openapi({ - description: "The ID of the key", - example: "abc123", - }), - name: z.string().optional().openapi({ - description: "The name of the key", - example: "API Key 1", - }), - remaining: z.number().int().optional().openapi({ - description: "The remaining number of requests for the key", - example: 1000, - }), - identity: z - .object({ - id: z.string().openapi({ - description: "The identity ID associated with the key", - example: "identity123", - }), - externalId: z.string().openapi({ - description: "The external identity ID associated with the key", - example: "ext123", - }), - }) - .optional() - .openapi({ - description: "The identity object associated with the key", - }), - meta: z - .object({}) - .optional() - .openapi({ - description: "Metadata associated with the key", - example: { role: "admin", plan: "premium" }, - }), - createdAt: z.number().int().openapi({ - description: "The timestamp when the key was created", - example: 1620000000000, - }), - enabled: z.boolean().openapi({ - description: "Whether the key is enabled", - example: true, - }), - environment: z.string().optional().openapi({ - description: "The environment the key is associated with", - example: "production", - }), - }), - }, - }, - }, - ...openApiErrorResponses, - }, -}); - -export type Route = typeof route; -export type V1KeysGetKeyResponse = z.infer< - (typeof route.responses)[200]["content"]["application/json"]["schema"] ->; - -export const registerV1KeysWhoAmi = (app: App) => - app.openapi(route, async (c) => { - const { key: secret } = c.req.valid("query"); - const { cache, db } = c.get("services"); - const hash = await sha256(secret); - const { val: data, err } = await cache.keyByHash.swr(hash, async () => { - const dbRes = await db.readonly.query.keys.findFirst({ - where: (table, { eq, and, isNull }) => and(eq(table.hash, hash), isNull(table.deletedAt)), - with: { - encrypted: true, - keyAuth: { - with: { - api: true, - }, - }, - identity: true, - }, - }); - - if (!dbRes) { - return null; - } - - return { - key: { - ...dbRes, - encrypted: dbRes.encrypted ?? null, - }, - api: dbRes.keyAuth.api, - identity: dbRes.identity - ? { - id: dbRes.identity.id, - externalId: dbRes.identity.externalId, - meta: dbRes.identity.meta ?? {}, - } - : null, - } as any; // this was neccessary so that we don't need to return the workspace and other types defined in keyByHash - }); - - - if (err) { - throw new UnkeyApiError({ - code: "INTERNAL_SERVER_ERROR", - message: `unable to load key: ${err.message}`, - }); - } - if (!data) { - throw new UnkeyApiError({ - code: "NOT_FOUND", - message: `key ${secret} not found`, - }); - } - const { api, key } = data; - const auth = await rootKeyAuth( - c, - buildUnkeyQuery(({ or }) => or("*", "api.*.read_key", `api.${api.id}.read_key`)), - ); - - if (key.workspaceId !== auth.authorizedWorkspaceId) { - throw new UnkeyApiError({ - code: "NOT_FOUND", - message: `key ${secret} not found`, - }); - } - let meta = key.meta ? JSON.parse(key.meta) : undefined; - if (!meta || Object.keys(meta).length === 0) { - meta = undefined; - } - - - return c.json({ - id: key.id, - name: key.name ?? undefined, - remaining: key.remaining ?? undefined, - identity: data.identity - ? { - id: data.identity.id, - externalId: data.identity.externalId, - } - : undefined, - meta: key.meta ? JSON.parse(key.meta) : undefined, - createdAt: key.createdAt.getTime(), - enabled: key.enabled, - environment: key.environment ?? undefined, - }); - }); \ No newline at end of file diff --git a/apps/api/src/routes/v1_keys_whoAmI.error.test.ts b/apps/api/src/routes/v1_keys_whoAmI.error.test.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/api/src/routes/v1_keys_whoAmI.happy.test.ts b/apps/api/src/routes/v1_keys_whoAmI.happy.test.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/api/src/routes/v1_keys_whoAmI.security.test.ts b/apps/api/src/routes/v1_keys_whoAmI.security.test.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/api/src/routes/v1_keys_whoAmI.ts b/apps/api/src/routes/v1_keys_whoAmI.ts new file mode 100644 index 0000000000..feecc0b905 --- /dev/null +++ b/apps/api/src/routes/v1_keys_whoAmI.ts @@ -0,0 +1,171 @@ +import type { App } from "@/pkg/hono/app"; +import { createRoute, z } from "@hono/zod-openapi"; + +import { rootKeyAuth } from "@/pkg/auth/root_key"; +import { UnkeyApiError, openApiErrorResponses } from "@/pkg/errors"; +import { sha256 } from "@unkey/hash"; +import { buildUnkeyQuery } from "@unkey/rbac"; + +const route = createRoute({ + tags: ["keys"], + operationId: "whoAmI", + method: "post", + path: "/v1/keys.whoAmI", + security: [{ bearerAuth: [] }], + request: { + body: { + required: true, + content: { + "application/json": { + schema: z.object({ + key: z.string().min(1).openapi({ + description: "The actual key to fetch", + example: "sk_123", + }), + }), + }, + }, + }, + }, + + responses: { + 200: { + description: "The configuration for a single key", + content: { + "application/json": { + schema: z.object({ + id: z.string().openapi({ + description: "The ID of the key", + example: "key_123", + }), + name: z.string().optional().openapi({ + description: "The name of the key", + example: "API Key 1", + }), + remaining: z.number().int().optional().openapi({ + description: "The remaining number of requests for the key", + example: 1000, + }), + identity: z + .object({ + id: z.string().openapi({ + description: "The identity ID associated with the key", + example: "id_123", + }), + externalId: z.string().openapi({ + description: "The external identity ID associated with the key", + example: "ext123", + }), + }) + .optional() + .openapi({ + description: "The identity object associated with the key", + }), + meta: z + .record(z.unknown()) + .optional() + .openapi({ + description: "Metadata associated with the key", + example: { role: "admin", plan: "premium" }, + }), + createdAt: z.number().int().openapi({ + description: "The timestamp in milliseconds when the key was created", + example: 1620000000000, + }), + enabled: z.boolean().openapi({ + description: "Whether the key is enabled", + example: true, + }), + environment: z.string().optional().openapi({ + description: "The environment the key is associated with", + example: "production", + }), + }), + }, + }, + }, + ...openApiErrorResponses, + }, +}); + +export type Route = typeof route; +export type V1KeysWhoAmIResponse = z.infer< + (typeof route.responses)[200]["content"]["application/json"]["schema"] +>; + +export const registerV1KeysWhoAmi = (app: App) => + app.openapi(route, async (c) => { + const { key: secret } = c.req.valid("json"); + const { cache, db } = c.get("services"); + const hash = await sha256(secret); + const { val: data, err } = await cache.keyByHash.swr(hash, async () => { + const dbRes = await db.readonly.query.keys.findFirst({ + where: (table, { eq, and, isNull }) => and(eq(table.hash, hash), isNull(table.deletedAt)), + with: { + keyAuth: { + with: { + api: true, + }, + }, + identity: true, + }, + }); + + if (!dbRes) { + return null; + } + + return { + key: { + ...dbRes, + }, + api: dbRes.keyAuth.api, + identity: dbRes.identity, + } as any; // this was neccessary so that we don't need to return the workspace and other types defined in keyByHash + }); + + if (err) { + throw new UnkeyApiError({ + code: "INTERNAL_SERVER_ERROR", + message: `unable to load key: ${err.message}`, + }); + } + if (!data) { + throw new UnkeyApiError({ + code: "NOT_FOUND", + message: "Key not found", + }); + } + const { api, key } = data; + const auth = await rootKeyAuth( + c, + buildUnkeyQuery(({ or }) => or("*", "api.*.read_key", `api.${api.id}.read_key`)), + ); + + if (key.workspaceId !== auth.authorizedWorkspaceId) { + throw new UnkeyApiError({ + code: "NOT_FOUND", + message: "Key not found", + }); + } + let meta = key.meta ? JSON.parse(key.meta) : undefined; + if (!meta || Object.keys(meta).length === 0) { + meta = undefined; + } + + return c.json({ + id: key.id, + name: key.name ?? undefined, + remaining: key.remaining ?? undefined, + identity: data.identity + ? { + id: data.identity.id, + externalId: data.identity.externalId, + } + : undefined, + meta: key.meta ? JSON.parse(key.meta) : undefined, + createdAt: key.createdAt.getTime(), + enabled: key.enabled, + environment: key.environment ?? undefined, + }); + }); diff --git a/apps/api/src/worker.ts b/apps/api/src/worker.ts index a21b419f11..4583c2156e 100644 --- a/apps/api/src/worker.ts +++ b/apps/api/src/worker.ts @@ -9,11 +9,11 @@ import { registerV1ApisListKeys } from "./routes/v1_apis_listKeys"; import { registerV1KeysCreateKey } from "./routes/v1_keys_createKey"; import { registerV1KeysDeleteKey } from "./routes/v1_keys_deleteKey"; import { registerV1KeysGetKey } from "./routes/v1_keys_getKey"; -import { registerV1KeysWhoAmi } from "./routes/v1_keys_getWhoAmI"; import { registerV1KeysGetVerifications } from "./routes/v1_keys_getVerifications"; import { registerV1KeysUpdate } from "./routes/v1_keys_updateKey"; import { registerV1KeysUpdateRemaining } from "./routes/v1_keys_updateRemaining"; import { registerV1KeysVerifyKey } from "./routes/v1_keys_verifyKey"; +import { registerV1KeysWhoAmi } from "./routes/v1_keys_whoAmI"; import { registerV1Liveness } from "./routes/v1_liveness"; import { registerV1RatelimitLimit } from "./routes/v1_ratelimit_limit"; From ef054c3b830e5715cf0a703f03e0fc4c302ecbba Mon Sep 17 00:00:00 2001 From: Harsh Shrikant Bhat <90265455+harshsbhat@users.noreply.github.com> Date: Sat, 5 Oct 2024 16:37:13 +0530 Subject: [PATCH 03/11] 2 tests one still remaining --- .../src/routes/v1_keys_whoAmI.error.test.ts | 34 ++++++ .../routes/v1_keys_whoAmI.security.test.ts | 101 ++++++++++++++++++ apps/api/src/routes/v1_keys_whoAmI.ts | 9 +- apps/api/src/worker.ts | 4 +- 4 files changed, 143 insertions(+), 5 deletions(-) diff --git a/apps/api/src/routes/v1_keys_whoAmI.error.test.ts b/apps/api/src/routes/v1_keys_whoAmI.error.test.ts index e69de29bb2..7dc3bfd68e 100644 --- a/apps/api/src/routes/v1_keys_whoAmI.error.test.ts +++ b/apps/api/src/routes/v1_keys_whoAmI.error.test.ts @@ -0,0 +1,34 @@ +import { newId } from "@unkey/id"; +import { KeyV1 } from "@unkey/keys"; +import { IntegrationHarness } from "src/pkg/testutil/integration-harness"; +import { expect, test } from "vitest"; + +import type { V1KeysWhoAmIRequest, V1KeysWhoAmIResponse } from "./v1_keys_whoAmI"; + +test("when the key does not exist", async (t) => { + const h = await IntegrationHarness.init(t); + const apiId = newId("api"); + const key = new KeyV1({ prefix: "test", byteLength: 16 }).toString(); + + const root = await h.createRootKey([`api.${apiId}.read_key`]); + + const res = await h.post({ + url: "/v1/keys.whoAmI", + headers: { + Authorization: `Bearer ${root.key}`, + "Content-Type": "application/json", + }, + body: { + key: key, + }, + }); + + expect(res.status).toEqual(404); + expect(res.body).toMatchObject({ + error: { + code: "NOT_FOUND", + docs: "https://unkey.dev/docs/api-reference/errors/code/NOT_FOUND", + message: "Key not found", + }, + }); +}); diff --git a/apps/api/src/routes/v1_keys_whoAmI.security.test.ts b/apps/api/src/routes/v1_keys_whoAmI.security.test.ts index e69de29bb2..35a682af44 100644 --- a/apps/api/src/routes/v1_keys_whoAmI.security.test.ts +++ b/apps/api/src/routes/v1_keys_whoAmI.security.test.ts @@ -0,0 +1,101 @@ +import { randomUUID } from "node:crypto"; +import { runCommonRouteTests } from "@/pkg/testutil/common-tests"; +import { schema } from "@unkey/db"; +import { sha256 } from "@unkey/hash"; +import { newId } from "@unkey/id"; +import { KeyV1 } from "@unkey/keys"; +import { IntegrationHarness } from "src/pkg/testutil/integration-harness"; + +import { describe, expect, test } from "vitest"; +import type { V1KeysWhoAmIRequest, V1KeysWhoAmIResponse } from "./v1_keys_whoAmI"; + +runCommonRouteTests({ + prepareRequest: async (h) => { + const keyId = newId("test"); + const key = new KeyV1({ prefix: "test", byteLength: 16 }).toString(); + const hash = await sha256(key); + await h.db.primary.insert(schema.keys).values({ + id: keyId, + keyAuthId: h.resources.userKeyAuth.id, + hash: hash, + start: key.slice(0, 8), + workspaceId: h.resources.userWorkspace.id, + createdAt: new Date(), + }); + return { + method: "POST", + url: "/v1/keys.whoAmI", + headers: { + "Content-Type": "application/json", + }, + body: { + key: key, + }, + }; + }, +}); + +describe("correct permissions", () => { + describe.each([ + { name: "legacy", roles: ["*"] }, + { name: "legacy and more", roles: ["*", randomUUID()] }, + { name: "wildcard api", roles: ["api.*.read_key", "api.*.read_api"] }, + { + name: "wildcard mixed", + roles: ["api.*.read_key", (apiId: string) => `api.${apiId}.read_api`], + }, + { + name: "wildcard mixed 2", + roles: ["api.*.read_api", (apiId: string) => `api.${apiId}.read_key`], + }, + { name: "wildcard and more", roles: ["api.*.read_key", "api.*.read_api", randomUUID()] }, + { + name: "specific apiId", + roles: [ + (apiId: string) => `api.${apiId}.read_key`, + (apiId: string) => `api.${apiId}.read_api`, + ], + }, + { + name: "specific apiId and more", + roles: [ + (apiId: string) => `api.${apiId}.read_key`, + (apiId: string) => `api.${apiId}.read_api`, + randomUUID(), + ], + }, + ])("$name", ({ roles }) => { + test("returns 200", async (t) => { + const h = await IntegrationHarness.init(t); + + const keyId = newId("test"); + const key = new KeyV1({ prefix: "test", byteLength: 16 }).toString(); + const hash = await sha256(key); + await h.db.primary.insert(schema.keys).values({ + id: keyId, + keyAuthId: h.resources.userKeyAuth.id, + hash: hash, + start: key.slice(0, 8), + workspaceId: h.resources.userWorkspace.id, + createdAt: new Date(), + }); + + const root = await h.createRootKey( + roles.map((role) => (typeof role === "string" ? role : role(h.resources.userApi.id))), + ); + + const res = await h.post({ + url: "/v1/keys.whoAmI", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${root.key}`, + }, + body: { + key: key, + }, + }); + + expect(res.status, `expected 200, received: ${JSON.stringify(res, null, 2)}`).toEqual(200); + }); + }); +}); diff --git a/apps/api/src/routes/v1_keys_whoAmI.ts b/apps/api/src/routes/v1_keys_whoAmI.ts index feecc0b905..a6b86987d0 100644 --- a/apps/api/src/routes/v1_keys_whoAmI.ts +++ b/apps/api/src/routes/v1_keys_whoAmI.ts @@ -89,11 +89,14 @@ const route = createRoute({ }); export type Route = typeof route; +export type V1KeysWhoAmIRequest = z.infer< + (typeof route.request.body.content)["application/json"]["schema"] +>; export type V1KeysWhoAmIResponse = z.infer< (typeof route.responses)[200]["content"]["application/json"]["schema"] >; -export const registerV1KeysWhoAmi = (app: App) => +export const registerV1KeysWhoAmI = (app: App) => app.openapi(route, async (c) => { const { key: secret } = c.req.valid("json"); const { cache, db } = c.get("services"); @@ -121,7 +124,7 @@ export const registerV1KeysWhoAmi = (app: App) => }, api: dbRes.keyAuth.api, identity: dbRes.identity, - } as any; // this was neccessary so that we don't need to return the workspace and other types defined in keyByHash + } as any; // this was necessary so that we don't need to return the workspace and other types defined in keyByHash }); if (err) { @@ -163,7 +166,7 @@ export const registerV1KeysWhoAmi = (app: App) => externalId: data.identity.externalId, } : undefined, - meta: key.meta ? JSON.parse(key.meta) : undefined, + meta: meta, createdAt: key.createdAt.getTime(), enabled: key.enabled, environment: key.environment ?? undefined, diff --git a/apps/api/src/worker.ts b/apps/api/src/worker.ts index 4583c2156e..4831e5372f 100644 --- a/apps/api/src/worker.ts +++ b/apps/api/src/worker.ts @@ -13,7 +13,7 @@ import { registerV1KeysGetVerifications } from "./routes/v1_keys_getVerification import { registerV1KeysUpdate } from "./routes/v1_keys_updateKey"; import { registerV1KeysUpdateRemaining } from "./routes/v1_keys_updateRemaining"; import { registerV1KeysVerifyKey } from "./routes/v1_keys_verifyKey"; -import { registerV1KeysWhoAmi } from "./routes/v1_keys_whoAmI"; +import { registerV1KeysWhoAmI } from "./routes/v1_keys_whoAmI"; import { registerV1Liveness } from "./routes/v1_liveness"; import { registerV1RatelimitLimit } from "./routes/v1_ratelimit_limit"; @@ -68,7 +68,7 @@ registerV1Liveness(app); // keys registerV1KeysGetKey(app); -registerV1KeysWhoAmi(app); +registerV1KeysWhoAmI(app); registerV1KeysDeleteKey(app); registerV1KeysCreateKey(app); registerV1KeysVerifyKey(app); From 36a8f72f9eafa6cb48d4fb3a3cfe697deaa04fba Mon Sep 17 00:00:00 2001 From: Nazar Poshtarenko <32395926+unrenamed@users.noreply.github.com> Date: Sat, 5 Oct 2024 22:05:19 +0300 Subject: [PATCH 04/11] fix(www): add missing langs to analytics bento (#2214) * fix(www): add missing langs to analytics bento Also, add a copy button to the code editor for one-click code copying, removing the need for manual selection. * refactor(www): extract langs SVG icons into separate file Also, rename and move components to components/ui folder. * refactor(www): extract copy code snippet button to separate file * refactor(www): apply coderabbitai review comments * [autofix.ci] apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- apps/www/app/code-examples.tsx | 223 ++---------- .../components/analytics/analytics-bento.tsx | 342 +++++++++--------- apps/www/components/svg/lang-icons.tsx | 135 +++++++ apps/www/components/ui/code-editor.tsx | 36 ++ apps/www/components/ui/copy-code-button.tsx | 70 ++++ 5 files changed, 444 insertions(+), 362 deletions(-) create mode 100644 apps/www/components/svg/lang-icons.tsx create mode 100644 apps/www/components/ui/code-editor.tsx create mode 100644 apps/www/components/ui/copy-code-button.tsx diff --git a/apps/www/app/code-examples.tsx b/apps/www/app/code-examples.tsx index 5462c6ff23..ac5127708c 100644 --- a/apps/www/app/code-examples.tsx +++ b/apps/www/app/code-examples.tsx @@ -1,7 +1,18 @@ "use client"; -import { Editor } from "@/components/analytics/analytics-bento"; import { PrimaryButton, SecondaryButton } from "@/components/button"; import { SectionTitle } from "@/components/section"; +import type { LangIconProps } from "@/components/svg/lang-icons"; +import { + CurlIcon, + ElixirIcon, + GoIcon, + JavaIcon, + PythonIcon, + RustIcon, + TSIcon, +} from "@/components/svg/lang-icons"; +import { CodeEditor } from "@/components/ui/code-editor"; +import { CopyCodeSnippetButton } from "@/components/ui/copy-code-button"; import { MeteorLines } from "@/components/ui/meteorLines"; import { cn } from "@/lib/utils"; import * as TabsPrimitive from "@radix-ui/react-tabs"; @@ -57,142 +68,6 @@ const editorTheme = { ], } satisfies PrismTheme; -type IconProps = { - active: boolean; -}; - -const JavaIcon: React.FC = ({ active }) => ( - - - - - - -); - -const ElixirIcon: React.FC = ({ active }) => ( - - - -); - -const RustIcon: React.FC = ({ active }) => ( - - - - - - -); - -const CurlIcon: React.FC = ({ active }) => ( - - - - - - - - - - - - -); - -const PythonIcon: React.FC = ({ active }) => ( - - - - - - - - - -); - -const TSIcon: React.FC = ({ active }) => ( - - - - - - - -); - -const GoIcon: React.FC = ({ active }) => ( - - - -); - const typescriptCodeBlock = `import { verifyKey } from '@unkey/api'; const { result, error } = await verifyKey({ @@ -503,7 +378,7 @@ public class APIController { type Framework = { name: string; - Icon: React.FC; + Icon: React.FC; codeBlock: string; editorLanguage: string; }; @@ -514,31 +389,31 @@ const languagesList = { name: "Typescript", Icon: TSIcon, codeBlock: typescriptCodeBlock, - editorLanguage: "ts", + editorLanguage: "tsx", }, { name: "Next.js", Icon: TSIcon, codeBlock: nextJsCodeBlock, - editorLanguage: "ts", + editorLanguage: "tsx", }, { name: "Nuxt", codeBlock: nuxtCodeBlock, Icon: TSIcon, - editorLanguage: "ts", + editorLanguage: "tsx", }, { name: "Hono", Icon: TSIcon, codeBlock: honoCodeBlock, - editorLanguage: "ts", + editorLanguage: "tsx", }, { name: "Ratelimiting", Icon: TSIcon, codeBlock: tsRatelimitCodeBlock, - editorLanguage: "ts", + editorLanguage: "tsx", }, ], Python: [ @@ -574,13 +449,13 @@ const languagesList = { name: "Verify key", Icon: JavaIcon, codeBlock: javaVerifyKeyCodeBlock, - editorLanguage: "ts", + editorLanguage: "tsx", }, { name: "Create key", Icon: JavaIcon, codeBlock: javaCreateKeyCodeBlock, - editorLanguage: "ts", + editorLanguage: "tsx", }, ], Elixir: [ @@ -588,7 +463,7 @@ const languagesList = { name: "Verify key", Icon: ElixirIcon, codeBlock: elixirCodeBlock, - editorLanguage: "ts", + editorLanguage: "tsx", }, ], Rust: [ @@ -644,7 +519,7 @@ type Props = { type Language = "Typescript" | "Python" | "Rust" | "Golang" | "Curl" | "Elixir" | "Java"; type LanguagesList = { name: Language; - Icon: React.FC; + Icon: React.FC; }; const languages = [ { name: "Typescript", Icon: TSIcon }, @@ -696,7 +571,6 @@ export const CodeExamples: React.FC = ({ className }) => { return currentFramework?.codeBlock || ""; } - const [copied, setCopied] = useState(false); return (
= ({ className }) => { setFramework={setFramework} />
- - + />
diff --git a/apps/www/components/analytics/analytics-bento.tsx b/apps/www/components/analytics/analytics-bento.tsx index f0bd5b417d..65d9444c2d 100644 --- a/apps/www/components/analytics/analytics-bento.tsx +++ b/apps/www/components/analytics/analytics-bento.tsx @@ -1,19 +1,35 @@ "use client"; +import type { LangIconProps } from "@/components/svg/lang-icons"; +import { CurlIcon, GoIcon, PythonIcon, RustIcon, TSIcon } from "@/components/svg/lang-icons"; +import { CopyCodeSnippetButton } from "@/components/ui/copy-code-button"; import { cn } from "@/lib/utils"; import { motion } from "framer-motion"; import { Wand2 } from "lucide-react"; -import { Highlight, type PrismTheme } from "prism-react-renderer"; +import type { PrismTheme } from "prism-react-renderer"; import { useState } from "react"; import { PrimaryButton } from "../button"; import { AnalyticsStars } from "../svg/analytics-stars"; import { WebAppLight } from "../svg/web-app-light"; +import { CodeEditor } from "../ui/code-editor"; -export const theme = { +const theme = { plain: { color: "#F8F8F2", backgroundColor: "#282A36", }, styles: [ + { + types: ["keyword"], + style: { + color: "#9D72FF", + }, + }, + { + types: ["function"], + style: { + color: "#FB3186", + }, + }, { types: ["string"], style: { @@ -32,28 +48,15 @@ export const theme = { color: "#FB3186", }, }, + { + types: ["comment"], + style: { + color: "#4D4D4D", + }, + }, ], } satisfies PrismTheme; -const codeBlock = `curl --request GET \\ - --url https://api.unkey.dev/v1/keys.getKey \\ - --header 'Authorization: ' - { - "apiId": "api_1234", - "createdAt": 123, - "deletedAt": 123, - "expires": 123, - "id": "key_1234", - "meta": { - "roles": [ - "admin", - "user" - ], - "stripeCustomerId": "cus_1234" - } - } -`; - export function AnalyticsBento() { const [showApi, toggleShowApi] = useState(false); @@ -76,7 +79,109 @@ export function AnalyticsBento() { ); } +type Language = { + name: string; + Icon: React.FC; + codeBlock: string; +}; + +type LanguageName = "TypeScript" | "Python" | "Rust" | "Go" | "cURL"; + +const curlCodeBlock = `curl --request GET \\ + --url https://api.unkey.dev/v1/keys.getKey?keyId=key_123 \\ + --header 'Authorization: Bearer ' +`; + +const tsCodeBlock = `import { Unkey } from "@unkey/api"; + +const unkey = new Unkey({ rootKey: "" }); + +const { result, error } = await unkey.keys.get({ keyId: "key_123" }); + +if ( error ) { + // handle network error +} + +// handle request +`; + +const pythonCodeBlock = `import asyncio +import os +import unkey + +async def main() -> None: + client = unkey.Client("") + await client.start() + + result = await client.keys.get_key("key_123") + + if result.is_ok: + data = result.unwrap() + print(data.id) + else: + print(result.unwrap_err()) + + await client.close() +`; + +const goCodeBlock = `package main + +import ( + "context" + "log" + + unkeygo "github.com/unkeyed/unkey-go" + "github.com/unkeyed/unkey-go/models/operations" +) + +func main() { + s := unkeygo.New( + unkeygo.WithSecurity(""), + ) + + ctx := context.Background() + res, err := s.Keys.GetKey(ctx, operations.GetKeyRequest{ + KeyID: "key_123", + }) + if err != nil { + log.Fatal(err) + } + if res.Key != nil { + // handle response + } +} +`; + +const rustCodeBlock = `use unkey::models::GetKeyRequest; +use unkey::Client; + +async fn get_key() { + let c = Client::new(""); + let req = GetKeyRequest::new("key_123"); + + match c.get_key(req).await { + Ok(res) => println!("{res:?}"), + Err(err) => eprintln!("{err:?}"), + } +} +`; + +const languagesList = { + cURL: { Icon: CurlIcon, name: "cURL", codeBlock: curlCodeBlock, editorLanguage: "tsx" }, + TypeScript: { Icon: TSIcon, name: "TypeScript", codeBlock: tsCodeBlock, editorLanguage: "tsx" }, + Python: { + Icon: PythonIcon, + name: "Python", + codeBlock: pythonCodeBlock, + editorLanguage: "python", + }, + Go: { Icon: GoIcon, name: "Go", codeBlock: goCodeBlock, editorLanguage: "go" }, + Rust: { Icon: RustIcon, name: "Rust", codeBlock: rustCodeBlock, editorLanguage: "rust" }, +}; + function AnalyticsApiView() { + const [language, setLanguage] = useState("cURL"); + return ( -
-
-
- -
cURL
-
-
-
- +
+ +
+ +
); } +function LanguageSwitcher({ + languages, + currentLanguage, + setLanguage, +}: { + languages: Language[]; + currentLanguage: LanguageName; + setLanguage: React.Dispatch>; +}) { + return ( +
+
+ {languages.map(({ Icon, name }) => ( + + ))} +
+
+ ); +} + function AnalyticsWebAppView() { function Tab({ backgroundColor, @@ -332,7 +478,7 @@ function AnalyticsWebAppView() { whileInView="visible" className="w-full overflow-x-hidden" > -
+
@@ -650,140 +796,6 @@ function AnalyticsWebAppView() { ); } -export function Editor({ - codeBlock, - language, - theme, -}: { codeBlock: string; language: string; theme?: PrismTheme }) { - return ( - - {({ tokens, getLineProps, getTokenProps }) => { - const amountLines = tokens.length; - const gutterPadLength = Math.max(String(amountLines).length, 2); - return ( -
-            {tokens.map((line, i) => {
-              const lineNumber = i + 1;
-              const paddedLineGutter = String(lineNumber).padStart(gutterPadLength, " ");
-              return (
-                // biome-ignore lint/suspicious/noArrayIndexKey: I got nothing better right now
-                
- {paddedLineGutter} - {line.map((token, key) => ( - - ))} -
- ); - })} -
- ); - }} -
- ); -} - -export function TerminalIcon({ className }: { className?: string }) { - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -} - export function BentoText() { return (
diff --git a/apps/www/components/svg/lang-icons.tsx b/apps/www/components/svg/lang-icons.tsx new file mode 100644 index 0000000000..88c362df6f --- /dev/null +++ b/apps/www/components/svg/lang-icons.tsx @@ -0,0 +1,135 @@ +export type LangIconProps = { + active: boolean; +}; + +export const JavaIcon: React.FC = ({ active }) => ( + + + + + + +); + +export const ElixirIcon: React.FC = ({ active }) => ( + + + +); + +export const RustIcon: React.FC = ({ active }) => ( + + + + + + +); + +export const CurlIcon: React.FC = ({ active }) => ( + + + + + + + + + + + + +); + +export const PythonIcon: React.FC = ({ active }) => ( + + + + + + + + + +); + +export const TSIcon: React.FC = ({ active }) => ( + + + + + + + +); + +export const GoIcon: React.FC = ({ active }) => ( + + + +); diff --git a/apps/www/components/ui/code-editor.tsx b/apps/www/components/ui/code-editor.tsx new file mode 100644 index 0000000000..d6637d5e6c --- /dev/null +++ b/apps/www/components/ui/code-editor.tsx @@ -0,0 +1,36 @@ +import { Highlight, type PrismTheme } from "prism-react-renderer"; + +export function CodeEditor({ + codeBlock, + language, + theme, +}: { codeBlock: string; language: string; theme?: PrismTheme }) { + return ( + + {({ tokens, getLineProps, getTokenProps }) => { + const lineCount = tokens.length; + const gutterPadLength = Math.max(String(lineCount).length, 2); + return ( +
+            {tokens.map((line, i) => {
+              const lineNumber = i + 1;
+              const paddedLineGutter = String(lineNumber).padStart(gutterPadLength, " ");
+              return (
+                // biome-ignore lint/suspicious/noArrayIndexKey: I got nothing better right now
+                
+ {paddedLineGutter} + {line.map((token, key) => ( + + ))} +
+ ); + })} +
+ ); + }} +
+ ); +} diff --git a/apps/www/components/ui/copy-code-button.tsx b/apps/www/components/ui/copy-code-button.tsx new file mode 100644 index 0000000000..f2eb008dbd --- /dev/null +++ b/apps/www/components/ui/copy-code-button.tsx @@ -0,0 +1,70 @@ +import { useState } from "react"; + +type Props = { + textToCopy: string; + className?: string; +}; + +export function CopyCodeSnippetButton(props: Props) { + const [copied, setCopied] = useState(false); + + return ( + + ); +} + +function CheckmarkCircle() { + return ( + + + + + ); +} + +function CopyIcon() { + return ( + + + + + + + + + + ); +} From e8148d3e4c06a5b068048b2c429e43c3bc2f77f1 Mon Sep 17 00:00:00 2001 From: chronark Date: Sat, 5 Oct 2024 22:08:36 +0200 Subject: [PATCH 05/11] fix: create bucket if it doesn't exist --- apps/api/src/pkg/audit.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/api/src/pkg/audit.ts b/apps/api/src/pkg/audit.ts index a77419be22..701ef3712d 100644 --- a/apps/api/src/pkg/audit.ts +++ b/apps/api/src/pkg/audit.ts @@ -42,7 +42,7 @@ export async function insertGenericAuditLogs( const { cache, logger, db } = c.get("services"); for (const log of arr) { - const { val: bucket, err } = await cache.auditLogBucketByWorkspaceIdAndName.swr( + let { val: bucket, err } = await cache.auditLogBucketByWorkspaceIdAndName.swr( [log.workspaceId, log.bucket].join(":"), async () => { const bucket = await (tx ?? db.primary).query.auditLogBucket.findFirst({ @@ -66,10 +66,14 @@ export async function insertGenericAuditLogs( } if (!bucket) { - logger.error("Could not find audit log bucket for workspace", { + const bucketId = newId("auditLogBucket"); + await (tx ?? db.primary).insert(schema.auditLogBucket).values({ + id: bucketId, workspaceId: log.workspaceId, + name: log.bucket, + retentionDays: 90, }); - continue; + bucket = { id: bucketId }; } const auditLogId = newId("auditLog"); From 332963e0b2ae54956f74534d80503ce1d58dcf67 Mon Sep 17 00:00:00 2001 From: chronark Date: Sat, 5 Oct 2024 22:10:22 +0200 Subject: [PATCH 06/11] fix: revalidate cache --- apps/api/src/pkg/audit.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/api/src/pkg/audit.ts b/apps/api/src/pkg/audit.ts index 701ef3712d..34179fec63 100644 --- a/apps/api/src/pkg/audit.ts +++ b/apps/api/src/pkg/audit.ts @@ -42,8 +42,9 @@ export async function insertGenericAuditLogs( const { cache, logger, db } = c.get("services"); for (const log of arr) { + const cacheKey = [log.workspaceId, log.bucket].join(":"); let { val: bucket, err } = await cache.auditLogBucketByWorkspaceIdAndName.swr( - [log.workspaceId, log.bucket].join(":"), + cacheKey, async () => { const bucket = await (tx ?? db.primary).query.auditLogBucket.findFirst({ where: (table, { eq, and }) => @@ -74,6 +75,7 @@ export async function insertGenericAuditLogs( retentionDays: 90, }); bucket = { id: bucketId }; + await cache.auditLogBucketByWorkspaceIdAndName.remove(cacheKey); } const auditLogId = newId("auditLog"); From 94aa1f5177437ec4489f3b2531acd15534de121d Mon Sep 17 00:00:00 2001 From: Anne Deepa Prasanna Date: Sun, 6 Oct 2024 14:35:34 +0530 Subject: [PATCH 07/11] docs: fix typo and remove the weird line (#2223) --- apps/docs/libraries/ts/sdk/overview.mdx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/docs/libraries/ts/sdk/overview.mdx b/apps/docs/libraries/ts/sdk/overview.mdx index 309ce82dab..4acfe9f5fb 100644 --- a/apps/docs/libraries/ts/sdk/overview.mdx +++ b/apps/docs/libraries/ts/sdk/overview.mdx @@ -45,7 +45,7 @@ Always keep your root key safe and reset it `if` you suspect it has been comprom ## Response format -Because forgetting to handle thrown errors properly in javascript is often forgotten, we have decided to explicitely return errors to be handled. Fortunately typescript helps us here and everything is typesafe. +Because forgetting to handle thrown errors properly in javascript is often forgotten, we have decided to explicitly return errors to be handled. Fortunately typescript helps us here and everything is typesafe. Every method returns either an `error` or a `result` field, never both and never none. @@ -109,10 +109,10 @@ The constructor accepts some options to customize the behavior: ### Base Url -Run all requests agaisnt your own instance of unkey hosted on your own infrastructure. +Run all requests against your own instance of unkey hosted on your own infrastructure. - The age of the user. Cannot be less than 0 + ```ts const unkey = new Unkey({ @@ -125,7 +125,6 @@ const unkey = new Unkey({ By default the client will retry on network errors, you can customize this behavior: - From 314d1418343cc8ac981a0d904a6e5771fea09f5c Mon Sep 17 00:00:00 2001 From: Anne Deepa Prasanna Date: Sun, 6 Oct 2024 15:00:26 +0530 Subject: [PATCH 08/11] docs: correct spelling of EXPRED to EXPIRED (#2228) --- apps/docs/api-reference/errors/code/EXPIRED.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/docs/api-reference/errors/code/EXPIRED.mdx b/apps/docs/api-reference/errors/code/EXPIRED.mdx index 8aa3e3c32d..6f7e368e1d 100644 --- a/apps/docs/api-reference/errors/code/EXPIRED.mdx +++ b/apps/docs/api-reference/errors/code/EXPIRED.mdx @@ -1,5 +1,5 @@ --- -title: EXPRED +title: EXPIRED openapi-schema: ErrExpired --- From 2577ec8aa3c31c5236ba72f5a01d4409a4bcf345 Mon Sep 17 00:00:00 2001 From: Harsh Shrikant Bhat <90265455+harshsbhat@users.noreply.github.com> Date: Sun, 6 Oct 2024 18:25:52 +0530 Subject: [PATCH 09/11] Added third happy test --- .../src/routes/v1_keys_whoAmI.happy.test.ts | 86 +++++++++++++++++++ .../routes/v1_keys_whoAmI.security.test.ts | 29 +------ 2 files changed, 88 insertions(+), 27 deletions(-) diff --git a/apps/api/src/routes/v1_keys_whoAmI.happy.test.ts b/apps/api/src/routes/v1_keys_whoAmI.happy.test.ts index e69de29bb2..3f4b2a9d5e 100644 --- a/apps/api/src/routes/v1_keys_whoAmI.happy.test.ts +++ b/apps/api/src/routes/v1_keys_whoAmI.happy.test.ts @@ -0,0 +1,86 @@ +import { schema } from "@unkey/db"; +import { sha256 } from "@unkey/hash"; +import { newId } from "@unkey/id"; +import { KeyV1 } from "@unkey/keys"; +import { IntegrationHarness } from "src/pkg/testutil/integration-harness"; + +import { randomUUID } from "node:crypto"; +import { expect, test } from "vitest"; +import type { V1KeysWhoAmIRequest, V1KeysWhoAmIResponse } from "./v1_keys_whoAmI"; + +test("returns 200", async (t) => { + const h = await IntegrationHarness.init(t); + const root = await h.createRootKey(["api.*.read_key"]); + + const key = new KeyV1({ byteLength: 16 }).toString(); + const hash = await sha256(key); + const meta = JSON.stringify({ hello: "world" }); + + const keySchema = { + id: newId("test"), + keyAuthId: h.resources.userKeyAuth.id, + workspaceId: h.resources.userWorkspace.id, + start: "test", + name: "test", + remaining: 100, + enabled: true, + environment: "test", + hash: hash, + meta: meta, + createdAt: new Date(), + }; + await h.db.primary.insert(schema.keys).values(keySchema); + + const res = await h.post({ + url: "/v1/keys.whoAmI", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${root.key}`, + }, + body: { + key: key, + }, + }); + expect(res.status, `expected 200, received: ${JSON.stringify(res, null, 2)}`).toBe(200); + + expect(res.body.id).toEqual(keySchema.id); + expect(res.body.name).toEqual(keySchema.name); + expect(res.body.remaining).toEqual(keySchema.remaining); + expect(res.body.name).toEqual(keySchema.name); + expect(res.body.meta).toEqual(JSON.parse(keySchema.meta)); + expect(res.body.enabled).toEqual(keySchema.enabled); + expect(res.body.environment).toEqual(keySchema.environment); +}); + +test("returns identity", async (t) => { + const h = await IntegrationHarness.init(t); + + const identity = { + id: newId("identity"), + externalId: randomUUID(), + workspaceId: h.resources.userWorkspace.id, + }; + await h.db.primary.insert(schema.identities).values(identity); + + const { key } = await h.createKey({ identityId: identity.id }); + const root = await h.createRootKey([ + `api.${h.resources.userApi.id}.read_api`, + `api.${h.resources.userApi.id}.read_key`, + ]); + + const res = await h.post({ + url: "/v1/keys.whoAmI", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${root.key}`, + }, + body: { + key: key, + }, + }); + + expect(res.status, `expected 200, received: ${JSON.stringify(res, null, 2)}`).toBe(200); + expect(res.body.identity).toBeDefined(); + expect(res.body.identity!.id).toEqual(identity.id); + expect(res.body.identity!.externalId).toEqual(identity.externalId); +}); diff --git a/apps/api/src/routes/v1_keys_whoAmI.security.test.ts b/apps/api/src/routes/v1_keys_whoAmI.security.test.ts index 35a682af44..6a1cb35cba 100644 --- a/apps/api/src/routes/v1_keys_whoAmI.security.test.ts +++ b/apps/api/src/routes/v1_keys_whoAmI.security.test.ts @@ -1,9 +1,5 @@ import { randomUUID } from "node:crypto"; import { runCommonRouteTests } from "@/pkg/testutil/common-tests"; -import { schema } from "@unkey/db"; -import { sha256 } from "@unkey/hash"; -import { newId } from "@unkey/id"; -import { KeyV1 } from "@unkey/keys"; import { IntegrationHarness } from "src/pkg/testutil/integration-harness"; import { describe, expect, test } from "vitest"; @@ -11,17 +7,7 @@ import type { V1KeysWhoAmIRequest, V1KeysWhoAmIResponse } from "./v1_keys_whoAmI runCommonRouteTests({ prepareRequest: async (h) => { - const keyId = newId("test"); - const key = new KeyV1({ prefix: "test", byteLength: 16 }).toString(); - const hash = await sha256(key); - await h.db.primary.insert(schema.keys).values({ - id: keyId, - keyAuthId: h.resources.userKeyAuth.id, - hash: hash, - start: key.slice(0, 8), - workspaceId: h.resources.userWorkspace.id, - createdAt: new Date(), - }); + const { key } = await h.createKey(); return { method: "POST", url: "/v1/keys.whoAmI", @@ -67,18 +53,7 @@ describe("correct permissions", () => { ])("$name", ({ roles }) => { test("returns 200", async (t) => { const h = await IntegrationHarness.init(t); - - const keyId = newId("test"); - const key = new KeyV1({ prefix: "test", byteLength: 16 }).toString(); - const hash = await sha256(key); - await h.db.primary.insert(schema.keys).values({ - id: keyId, - keyAuthId: h.resources.userKeyAuth.id, - hash: hash, - start: key.slice(0, 8), - workspaceId: h.resources.userWorkspace.id, - createdAt: new Date(), - }); + const { key } = await h.createKey(); const root = await h.createRootKey( roles.map((role) => (typeof role === "string" ? role : role(h.resources.userApi.id))), From f8e6f2d8e5edeaf64217cbef291c273827bacff9 Mon Sep 17 00:00:00 2001 From: chronark Date: Mon, 7 Oct 2024 13:32:54 +0200 Subject: [PATCH 10/11] chore: add docs, changesets, fix path --- .changeset/late-colts-shout.md | 5 +++++ apps/api/src/routes/v1_keys_whoAmI.error.test.ts | 4 ++-- apps/api/src/routes/v1_keys_whoAmI.happy.test.ts | 6 +++--- .../src/routes/v1_keys_whoAmI.security.test.ts | 6 +++--- apps/api/src/routes/v1_keys_whoAmI.ts | 4 ++-- apps/api/src/worker.ts | 2 +- apps/docs/api-reference/keys/whoami.mdx | 15 +++++++++++++++ apps/docs/mint.json | 3 ++- 8 files changed, 33 insertions(+), 12 deletions(-) create mode 100644 .changeset/late-colts-shout.md create mode 100644 apps/docs/api-reference/keys/whoami.mdx diff --git a/.changeset/late-colts-shout.md b/.changeset/late-colts-shout.md new file mode 100644 index 0000000000..a8d9104b4e --- /dev/null +++ b/.changeset/late-colts-shout.md @@ -0,0 +1,5 @@ +--- +"api": minor +--- + +add /v1/keys.whoami route diff --git a/apps/api/src/routes/v1_keys_whoAmI.error.test.ts b/apps/api/src/routes/v1_keys_whoAmI.error.test.ts index 7dc3bfd68e..b592e964ee 100644 --- a/apps/api/src/routes/v1_keys_whoAmI.error.test.ts +++ b/apps/api/src/routes/v1_keys_whoAmI.error.test.ts @@ -3,7 +3,7 @@ import { KeyV1 } from "@unkey/keys"; import { IntegrationHarness } from "src/pkg/testutil/integration-harness"; import { expect, test } from "vitest"; -import type { V1KeysWhoAmIRequest, V1KeysWhoAmIResponse } from "./v1_keys_whoAmI"; +import type { V1KeysWhoAmIRequest, V1KeysWhoAmIResponse } from "./v1_keys_whoami"; test("when the key does not exist", async (t) => { const h = await IntegrationHarness.init(t); @@ -13,7 +13,7 @@ test("when the key does not exist", async (t) => { const root = await h.createRootKey([`api.${apiId}.read_key`]); const res = await h.post({ - url: "/v1/keys.whoAmI", + url: "/v1/keys.whoami", headers: { Authorization: `Bearer ${root.key}`, "Content-Type": "application/json", diff --git a/apps/api/src/routes/v1_keys_whoAmI.happy.test.ts b/apps/api/src/routes/v1_keys_whoAmI.happy.test.ts index 3f4b2a9d5e..b38c1c1247 100644 --- a/apps/api/src/routes/v1_keys_whoAmI.happy.test.ts +++ b/apps/api/src/routes/v1_keys_whoAmI.happy.test.ts @@ -6,7 +6,7 @@ import { IntegrationHarness } from "src/pkg/testutil/integration-harness"; import { randomUUID } from "node:crypto"; import { expect, test } from "vitest"; -import type { V1KeysWhoAmIRequest, V1KeysWhoAmIResponse } from "./v1_keys_whoAmI"; +import type { V1KeysWhoAmIRequest, V1KeysWhoAmIResponse } from "./v1_keys_whoami"; test("returns 200", async (t) => { const h = await IntegrationHarness.init(t); @@ -32,7 +32,7 @@ test("returns 200", async (t) => { await h.db.primary.insert(schema.keys).values(keySchema); const res = await h.post({ - url: "/v1/keys.whoAmI", + url: "/v1/keys.whoami", headers: { "Content-Type": "application/json", Authorization: `Bearer ${root.key}`, @@ -69,7 +69,7 @@ test("returns identity", async (t) => { ]); const res = await h.post({ - url: "/v1/keys.whoAmI", + url: "/v1/keys.whoami", headers: { "Content-Type": "application/json", Authorization: `Bearer ${root.key}`, diff --git a/apps/api/src/routes/v1_keys_whoAmI.security.test.ts b/apps/api/src/routes/v1_keys_whoAmI.security.test.ts index 6a1cb35cba..7725c70a14 100644 --- a/apps/api/src/routes/v1_keys_whoAmI.security.test.ts +++ b/apps/api/src/routes/v1_keys_whoAmI.security.test.ts @@ -3,14 +3,14 @@ import { runCommonRouteTests } from "@/pkg/testutil/common-tests"; import { IntegrationHarness } from "src/pkg/testutil/integration-harness"; import { describe, expect, test } from "vitest"; -import type { V1KeysWhoAmIRequest, V1KeysWhoAmIResponse } from "./v1_keys_whoAmI"; +import type { V1KeysWhoAmIRequest, V1KeysWhoAmIResponse } from "./v1_keys_whoami"; runCommonRouteTests({ prepareRequest: async (h) => { const { key } = await h.createKey(); return { method: "POST", - url: "/v1/keys.whoAmI", + url: "/v1/keys.whoami", headers: { "Content-Type": "application/json", }, @@ -60,7 +60,7 @@ describe("correct permissions", () => { ); const res = await h.post({ - url: "/v1/keys.whoAmI", + url: "/v1/keys.whoami", headers: { "Content-Type": "application/json", Authorization: `Bearer ${root.key}`, diff --git a/apps/api/src/routes/v1_keys_whoAmI.ts b/apps/api/src/routes/v1_keys_whoAmI.ts index a6b86987d0..a32b4172d4 100644 --- a/apps/api/src/routes/v1_keys_whoAmI.ts +++ b/apps/api/src/routes/v1_keys_whoAmI.ts @@ -8,9 +8,9 @@ import { buildUnkeyQuery } from "@unkey/rbac"; const route = createRoute({ tags: ["keys"], - operationId: "whoAmI", + operationId: "whoami", method: "post", - path: "/v1/keys.whoAmI", + path: "/v1/keys.whoami", security: [{ bearerAuth: [] }], request: { body: { diff --git a/apps/api/src/worker.ts b/apps/api/src/worker.ts index 4831e5372f..913cc4bfc5 100644 --- a/apps/api/src/worker.ts +++ b/apps/api/src/worker.ts @@ -13,7 +13,7 @@ import { registerV1KeysGetVerifications } from "./routes/v1_keys_getVerification import { registerV1KeysUpdate } from "./routes/v1_keys_updateKey"; import { registerV1KeysUpdateRemaining } from "./routes/v1_keys_updateRemaining"; import { registerV1KeysVerifyKey } from "./routes/v1_keys_verifyKey"; -import { registerV1KeysWhoAmI } from "./routes/v1_keys_whoAmI"; +import { registerV1KeysWhoAmI } from "./routes/v1_keys_whoami"; import { registerV1Liveness } from "./routes/v1_liveness"; import { registerV1RatelimitLimit } from "./routes/v1_ratelimit_limit"; diff --git a/apps/docs/api-reference/keys/whoami.mdx b/apps/docs/api-reference/keys/whoami.mdx new file mode 100644 index 0000000000..f442bca01c --- /dev/null +++ b/apps/docs/api-reference/keys/whoami.mdx @@ -0,0 +1,15 @@ +--- +title: Returns data about a key +openapi: get /v1/keys.whoami +--- + +## Changelog + +| Date | Changes | +|-------------|-----------------------------| +| Oct 07 2024 | Introduced endpoint | + + +You may not always have easy access to the `keyId` and therefore can't use [`/v1/keys.getKey`](/api-reference/keys/get). +This offers an escape hatch to send us the real key instead. + diff --git a/apps/docs/mint.json b/apps/docs/mint.json index bb51ddea24..d29f8ea1e7 100644 --- a/apps/docs/mint.json +++ b/apps/docs/mint.json @@ -234,7 +234,8 @@ "api-reference/keys/set-permissions", "api-reference/keys/add-roles", "api-reference/keys/remove-roles", - "api-reference/keys/set-roles" + "api-reference/keys/set-roles", + "api-reference/keys/whoami" ] }, { From 02e91fc376628c2da01ae8a8d802bbcd4bea3c5a Mon Sep 17 00:00:00 2001 From: Andreas Thomas Date: Mon, 7 Oct 2024 13:41:16 +0200 Subject: [PATCH 11/11] Update apps/docs/api-reference/keys/whoami.mdx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- apps/docs/api-reference/keys/whoami.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/docs/api-reference/keys/whoami.mdx b/apps/docs/api-reference/keys/whoami.mdx index f442bca01c..cc45d0f8ee 100644 --- a/apps/docs/api-reference/keys/whoami.mdx +++ b/apps/docs/api-reference/keys/whoami.mdx @@ -1,6 +1,6 @@ --- title: Returns data about a key -openapi: get /v1/keys.whoami +openapi: post /v1/keys.whoami --- ## Changelog