feat(api-key): Add CRUD functionalities to the api key module
This commit is contained in:
@@ -1 +1,14 @@
|
|||||||
// noop
|
import { CreateApiKeyDTO } from "@types"
|
||||||
|
import { ApiKeyType } from "@medusajs/utils"
|
||||||
|
|
||||||
|
export const createSecretKeyFixture: CreateApiKeyDTO = {
|
||||||
|
title: "Secret key",
|
||||||
|
type: ApiKeyType.SECRET,
|
||||||
|
created_by: "test",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createPublishableKeyFixture: CreateApiKeyDTO = {
|
||||||
|
title: "Test API Key",
|
||||||
|
type: ApiKeyType.PUBLISHABLE,
|
||||||
|
created_by: "test",
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +1,266 @@
|
|||||||
|
import crypto from "crypto"
|
||||||
import { Modules } from "@medusajs/modules-sdk"
|
import { Modules } from "@medusajs/modules-sdk"
|
||||||
import { IApiKeyModuleService } from "@medusajs/types"
|
import { IApiKeyModuleService } from "@medusajs/types"
|
||||||
|
import { ApiKeyType } from "@medusajs/utils"
|
||||||
import { moduleIntegrationTestRunner, SuiteOptions } from "medusa-test-utils"
|
import { moduleIntegrationTestRunner, SuiteOptions } from "medusa-test-utils"
|
||||||
|
import {
|
||||||
|
createSecretKeyFixture,
|
||||||
|
createPublishableKeyFixture,
|
||||||
|
} from "../__fixtures__"
|
||||||
|
|
||||||
jest.setTimeout(100000)
|
jest.setTimeout(100000)
|
||||||
|
|
||||||
|
const mockPublishableKeyBytes = () => {
|
||||||
|
jest.spyOn(crypto, "randomBytes").mockImplementationOnce(() => {
|
||||||
|
return Buffer.from(
|
||||||
|
"44de31ebcf085fa423fc584aa854067025e937a79edb565f472404345f0f23be",
|
||||||
|
"hex"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockSecretKeyBytes = () => {
|
||||||
|
jest
|
||||||
|
.spyOn(crypto, "randomBytes")
|
||||||
|
.mockImplementationOnce(() => {
|
||||||
|
return Buffer.from(
|
||||||
|
"44de31ebcf085fa423fc584aa854067025e937a79edb565f472404345f0f23be",
|
||||||
|
"hex"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.mockImplementationOnce(() => {
|
||||||
|
return Buffer.from("44de31ebcf085fa423fc584aa8540670", "hex")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
moduleIntegrationTestRunner({
|
moduleIntegrationTestRunner({
|
||||||
moduleName: Modules.API_KEY,
|
moduleName: Modules.API_KEY,
|
||||||
testSuite: ({
|
testSuite: ({
|
||||||
MikroOrmWrapper,
|
MikroOrmWrapper,
|
||||||
service,
|
service,
|
||||||
}: SuiteOptions<IApiKeyModuleService>) => {
|
}: SuiteOptions<IApiKeyModuleService>) => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
describe("API Key Module Service", () => {
|
describe("API Key Module Service", () => {
|
||||||
describe("noop", () => {
|
describe("creating a publishable API key", () => {
|
||||||
it("should run", function () {
|
it("should create it successfully", async function () {
|
||||||
expect(true).toBe(true)
|
mockPublishableKeyBytes()
|
||||||
|
const apiKey = await service.create(createPublishableKeyFixture)
|
||||||
|
|
||||||
|
expect(apiKey).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
title: "Test API Key",
|
||||||
|
type: ApiKeyType.PUBLISHABLE,
|
||||||
|
salt: "",
|
||||||
|
created_by: "test",
|
||||||
|
last_used_at: null,
|
||||||
|
revoked_by: null,
|
||||||
|
revoked_at: null,
|
||||||
|
redacted: "pk_44d***3be",
|
||||||
|
token:
|
||||||
|
"pk_44de31ebcf085fa423fc584aa854067025e937a79edb565f472404345f0f23be",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("creating a secret API key", () => {
|
||||||
|
it("should get created successfully", async function () {
|
||||||
|
mockSecretKeyBytes()
|
||||||
|
const apiKey = await service.create(createSecretKeyFixture)
|
||||||
|
|
||||||
|
expect(apiKey).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
title: "Secret key",
|
||||||
|
type: ApiKeyType.SECRET,
|
||||||
|
salt: "44de31ebcf085fa423fc584aa8540670",
|
||||||
|
created_by: "test",
|
||||||
|
last_used_at: null,
|
||||||
|
revoked_by: null,
|
||||||
|
revoked_at: null,
|
||||||
|
redacted: "sk_44d***3be",
|
||||||
|
token:
|
||||||
|
"sk_44de31ebcf085fa423fc584aa854067025e937a79edb565f472404345f0f23be",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should only allow creating one active token", async function () {
|
||||||
|
expect(
|
||||||
|
service.create([createSecretKeyFixture, createSecretKeyFixture])
|
||||||
|
).rejects.toThrow(
|
||||||
|
"You can only create one secret key at a time. You tried to create 2 secret keys."
|
||||||
|
)
|
||||||
|
|
||||||
|
await service.create(createSecretKeyFixture)
|
||||||
|
const err = await service
|
||||||
|
.create(createSecretKeyFixture)
|
||||||
|
.catch((e) => e)
|
||||||
|
expect(err.message).toEqual(
|
||||||
|
"You can only have one active secret key a time. Revoke or delete your existing key before creating a new one."
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should allow for at most two tokens, where one is revoked", async function () {
|
||||||
|
const firstApiKey = await service.create(createSecretKeyFixture)
|
||||||
|
await service.revoke({
|
||||||
|
id: firstApiKey.id,
|
||||||
|
revoked_by: "test",
|
||||||
|
})
|
||||||
|
|
||||||
|
await service.create(createSecretKeyFixture)
|
||||||
|
const err = await service
|
||||||
|
.create(createSecretKeyFixture)
|
||||||
|
.catch((e) => e)
|
||||||
|
expect(err.message).toEqual(
|
||||||
|
"You can only have one active secret key a time. Revoke or delete your existing key before creating a new one."
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("revoking API keys", () => {
|
||||||
|
it("should have the revoked at and revoked by set when a key is revoked", async function () {
|
||||||
|
const firstApiKey = await service.create(createSecretKeyFixture)
|
||||||
|
const revokedKey = await service.revoke({
|
||||||
|
id: firstApiKey.id,
|
||||||
|
revoked_by: "test",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(revokedKey).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
revoked_by: "test",
|
||||||
|
revoked_at: expect.any(Date),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not allow revoking an already revoked API key", async function () {
|
||||||
|
const firstApiKey = await service.create(createSecretKeyFixture)
|
||||||
|
await service.revoke({
|
||||||
|
id: firstApiKey.id,
|
||||||
|
revoked_by: "test",
|
||||||
|
})
|
||||||
|
|
||||||
|
const err = await service
|
||||||
|
.revoke({
|
||||||
|
id: firstApiKey.id,
|
||||||
|
revoked_by: "test2",
|
||||||
|
})
|
||||||
|
.catch((e) => e)
|
||||||
|
|
||||||
|
expect(err.message).toEqual(
|
||||||
|
`There are 1 secret keys that are already revoked.`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("updating an API key", () => {
|
||||||
|
it("should update the name successfully", async function () {
|
||||||
|
const createdApiKey = await service.create(createSecretKeyFixture)
|
||||||
|
|
||||||
|
const updatedApiKey = await service.update({
|
||||||
|
id: createdApiKey.id,
|
||||||
|
title: "New Name",
|
||||||
|
})
|
||||||
|
expect(updatedApiKey.title).toEqual("New Name")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not reflect any updates on other fields", async function () {
|
||||||
|
const createdApiKey = await service.create(createSecretKeyFixture)
|
||||||
|
|
||||||
|
const updatedApiKey = await service.update({
|
||||||
|
id: createdApiKey.id,
|
||||||
|
title: createdApiKey.title,
|
||||||
|
revoked_by: "test",
|
||||||
|
revoked_at: new Date(),
|
||||||
|
last_used_at: new Date(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// These should not be returned on an update
|
||||||
|
createdApiKey.token = ""
|
||||||
|
createdApiKey.salt = ""
|
||||||
|
expect(createdApiKey).toEqual(updatedApiKey)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("deleting API keys", () => {
|
||||||
|
it("should successfully delete existing api keys", async function () {
|
||||||
|
const createdApiKeys = await service.create([
|
||||||
|
createPublishableKeyFixture,
|
||||||
|
createSecretKeyFixture,
|
||||||
|
])
|
||||||
|
await service.delete([createdApiKeys[0].id, createdApiKeys[1].id])
|
||||||
|
|
||||||
|
const apiKeysInDatabase = await service.list()
|
||||||
|
expect(apiKeysInDatabase).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("retrieving API keys", () => {
|
||||||
|
it("should successfully return all existing api keys", async function () {
|
||||||
|
await service.create([
|
||||||
|
createPublishableKeyFixture,
|
||||||
|
createSecretKeyFixture,
|
||||||
|
])
|
||||||
|
|
||||||
|
const apiKeysInDatabase = await service.list()
|
||||||
|
expect(apiKeysInDatabase).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not return the token and salt for secret keys when listing", async function () {
|
||||||
|
await service.create([createSecretKeyFixture])
|
||||||
|
|
||||||
|
const apiKeysInDatabase = await service.list()
|
||||||
|
expect(apiKeysInDatabase).toHaveLength(1)
|
||||||
|
expect(apiKeysInDatabase[0].token).toBeFalsy()
|
||||||
|
expect(apiKeysInDatabase[0].salt).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return the token for publishable keys when listing", async function () {
|
||||||
|
await service.create([createPublishableKeyFixture])
|
||||||
|
|
||||||
|
const apiKeysInDatabase = await service.list()
|
||||||
|
expect(apiKeysInDatabase).toHaveLength(1)
|
||||||
|
expect(apiKeysInDatabase[0].token).toBeTruthy()
|
||||||
|
expect(apiKeysInDatabase[0].salt).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not return the token and salt for secret keys when listing and counting", async function () {
|
||||||
|
await service.create([createSecretKeyFixture])
|
||||||
|
|
||||||
|
const [apiKeysInDatabase] = await service.listAndCount()
|
||||||
|
expect(apiKeysInDatabase).toHaveLength(1)
|
||||||
|
expect(apiKeysInDatabase[0].token).toBeFalsy()
|
||||||
|
expect(apiKeysInDatabase[0].salt).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return the token for publishable keys when listing and counting", async function () {
|
||||||
|
await service.create([createPublishableKeyFixture])
|
||||||
|
|
||||||
|
const [apiKeysInDatabase] = await service.listAndCount()
|
||||||
|
expect(apiKeysInDatabase).toHaveLength(1)
|
||||||
|
expect(apiKeysInDatabase[0].token).toBeTruthy()
|
||||||
|
expect(apiKeysInDatabase[0].salt).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not return the token and salt for secret keys when retrieving", async function () {
|
||||||
|
const [createdApiKey] = await service.create([createSecretKeyFixture])
|
||||||
|
|
||||||
|
const apiKeyInDatabase = await service.retrieve(createdApiKey.id)
|
||||||
|
expect(apiKeyInDatabase.token).toBeFalsy()
|
||||||
|
expect(apiKeyInDatabase.salt).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return the token for publishable keys when retrieving", async function () {
|
||||||
|
const [createdApiKey] = await service.create([
|
||||||
|
createPublishableKeyFixture,
|
||||||
|
])
|
||||||
|
|
||||||
|
const apiKeyInDatabase = await service.retrieve(createdApiKey.id)
|
||||||
|
expect(apiKeyInDatabase.token).toBeTruthy()
|
||||||
|
expect(apiKeyInDatabase.salt).toBeFalsy()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
{
|
||||||
|
"namespaces": [
|
||||||
|
"public"
|
||||||
|
],
|
||||||
|
"name": "public",
|
||||||
|
"tables": [
|
||||||
|
{
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "text"
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "text"
|
||||||
|
},
|
||||||
|
"salt": {
|
||||||
|
"name": "salt",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "text"
|
||||||
|
},
|
||||||
|
"redacted": {
|
||||||
|
"name": "redacted",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "text"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "text"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "text"
|
||||||
|
},
|
||||||
|
"last_used_at": {
|
||||||
|
"name": "last_used_at",
|
||||||
|
"type": "timestamptz",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": true,
|
||||||
|
"length": 6,
|
||||||
|
"mappedType": "datetime"
|
||||||
|
},
|
||||||
|
"created_by": {
|
||||||
|
"name": "created_by",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "text"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamptz",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"length": 6,
|
||||||
|
"default": "now()",
|
||||||
|
"mappedType": "datetime"
|
||||||
|
},
|
||||||
|
"revoked_by": {
|
||||||
|
"name": "revoked_by",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": true,
|
||||||
|
"mappedType": "text"
|
||||||
|
},
|
||||||
|
"revoked_at": {
|
||||||
|
"name": "revoked_at",
|
||||||
|
"type": "timestamptz",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": true,
|
||||||
|
"length": 6,
|
||||||
|
"mappedType": "datetime"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"name": "api_key",
|
||||||
|
"schema": "public",
|
||||||
|
"indexes": [
|
||||||
|
{
|
||||||
|
"keyName": "IDX_api_key_type",
|
||||||
|
"columnNames": [
|
||||||
|
"type"
|
||||||
|
],
|
||||||
|
"composite": false,
|
||||||
|
"primary": false,
|
||||||
|
"unique": false,
|
||||||
|
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_api_key_type\" ON \"api_key\" (type)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keyName": "api_key_pkey",
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"composite": false,
|
||||||
|
"primary": true,
|
||||||
|
"unique": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"checks": [],
|
||||||
|
"foreignKeys": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Migration } from "@mikro-orm/migrations"
|
||||||
|
|
||||||
|
export class InitialSetup20240220155605 extends Migration {
|
||||||
|
async up(): Promise<void> {
|
||||||
|
this.addSql(
|
||||||
|
'create table if not exists "api_key" ("id" text not null, "token" text not null, "salt" text not null, "redacted" text not null, "title" text not null, "type" text not null, "last_used_at" timestamptz null, "created_by" text not null, "created_at" timestamptz not null default now(), "revoked_by" text null, "revoked_at" timestamptz null, constraint "api_key_pkey" primary key ("id"));'
|
||||||
|
)
|
||||||
|
this.addSql(
|
||||||
|
'CREATE INDEX IF NOT EXISTS "IDX_api_key_type" ON "api_key" (type);'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
import { generateEntityId } from "@medusajs/utils"
|
import {
|
||||||
|
createPsqlIndexStatementHelper,
|
||||||
|
generateEntityId,
|
||||||
|
} from "@medusajs/utils"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BeforeCreate,
|
BeforeCreate,
|
||||||
@@ -6,14 +9,45 @@ import {
|
|||||||
OnInit,
|
OnInit,
|
||||||
PrimaryKey,
|
PrimaryKey,
|
||||||
Property,
|
Property,
|
||||||
|
Enum,
|
||||||
} from "@mikro-orm/core"
|
} from "@mikro-orm/core"
|
||||||
|
|
||||||
// TODO:
|
const TypeIndex = createPsqlIndexStatementHelper({
|
||||||
|
tableName: "api_key",
|
||||||
|
columns: "type",
|
||||||
|
})
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
export default class ApiKey {
|
export default class ApiKey {
|
||||||
@PrimaryKey({ columnType: "text" })
|
@PrimaryKey({ columnType: "text" })
|
||||||
id: string
|
id: string
|
||||||
|
|
||||||
|
@Property({ columnType: "text" })
|
||||||
|
token: string
|
||||||
|
|
||||||
|
@Property({ columnType: "text" })
|
||||||
|
salt: string
|
||||||
|
|
||||||
|
@Property({ columnType: "text" })
|
||||||
|
redacted: string
|
||||||
|
|
||||||
|
@Property({ columnType: "text" })
|
||||||
|
title: string
|
||||||
|
|
||||||
|
@Property({ columnType: "text" })
|
||||||
|
@Enum({ items: ["publishable", "secret"] })
|
||||||
|
@TypeIndex.MikroORMIndex()
|
||||||
|
type: "publishable" | "secret"
|
||||||
|
|
||||||
|
@Property({
|
||||||
|
columnType: "timestamptz",
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
last_used_at: Date | null = null
|
||||||
|
|
||||||
|
@Property({ columnType: "text" })
|
||||||
|
created_by: string
|
||||||
|
|
||||||
@Property({
|
@Property({
|
||||||
onCreate: () => new Date(),
|
onCreate: () => new Date(),
|
||||||
columnType: "timestamptz",
|
columnType: "timestamptz",
|
||||||
@@ -21,13 +55,14 @@ export default class ApiKey {
|
|||||||
})
|
})
|
||||||
created_at: Date
|
created_at: Date
|
||||||
|
|
||||||
|
@Property({ columnType: "text", nullable: true })
|
||||||
|
revoked_by: string | null = null
|
||||||
|
|
||||||
@Property({
|
@Property({
|
||||||
onCreate: () => new Date(),
|
|
||||||
onUpdate: () => new Date(),
|
|
||||||
columnType: "timestamptz",
|
columnType: "timestamptz",
|
||||||
defaultRaw: "now()",
|
nullable: true,
|
||||||
})
|
})
|
||||||
updated_at: Date
|
revoked_at: Date | null = null
|
||||||
|
|
||||||
@BeforeCreate()
|
@BeforeCreate()
|
||||||
onCreate() {
|
onCreate() {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import crypto from "crypto"
|
||||||
|
import util from "util"
|
||||||
import {
|
import {
|
||||||
Context,
|
Context,
|
||||||
DAL,
|
DAL,
|
||||||
@@ -6,16 +8,21 @@ import {
|
|||||||
ModulesSdkTypes,
|
ModulesSdkTypes,
|
||||||
InternalModuleDeclaration,
|
InternalModuleDeclaration,
|
||||||
ModuleJoinerConfig,
|
ModuleJoinerConfig,
|
||||||
|
FindConfig,
|
||||||
} from "@medusajs/types"
|
} from "@medusajs/types"
|
||||||
|
import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config"
|
||||||
|
import { ApiKey } from "@models"
|
||||||
|
import { CreateApiKeyDTO, TokenDTO } from "@types"
|
||||||
import {
|
import {
|
||||||
|
ApiKeyType,
|
||||||
InjectManager,
|
InjectManager,
|
||||||
InjectTransactionManager,
|
InjectTransactionManager,
|
||||||
MedusaContext,
|
MedusaContext,
|
||||||
|
MedusaError,
|
||||||
ModulesSdkUtils,
|
ModulesSdkUtils,
|
||||||
} from "@medusajs/utils"
|
} from "@medusajs/utils"
|
||||||
|
|
||||||
import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config"
|
const scrypt = util.promisify(crypto.scrypt)
|
||||||
import { ApiKey } from "@models"
|
|
||||||
|
|
||||||
const generateMethodForModels = []
|
const generateMethodForModels = []
|
||||||
|
|
||||||
@@ -65,28 +72,60 @@ export default class ApiKeyModuleService<TEntity extends ApiKey = ApiKey>
|
|||||||
data: ApiKeyTypes.CreateApiKeyDTO | ApiKeyTypes.CreateApiKeyDTO[],
|
data: ApiKeyTypes.CreateApiKeyDTO | ApiKeyTypes.CreateApiKeyDTO[],
|
||||||
@MedusaContext() sharedContext: Context = {}
|
@MedusaContext() sharedContext: Context = {}
|
||||||
): Promise<ApiKeyTypes.ApiKeyDTO | ApiKeyTypes.ApiKeyDTO[]> {
|
): Promise<ApiKeyTypes.ApiKeyDTO | ApiKeyTypes.ApiKeyDTO[]> {
|
||||||
const createdApiKeys = await this.create_(data, sharedContext)
|
const [createdApiKeys, generatedTokens] = await this.create_(
|
||||||
|
Array.isArray(data) ? data : [data],
|
||||||
|
sharedContext
|
||||||
|
)
|
||||||
|
|
||||||
return await this.baseRepository_.serialize<
|
const serializedResponse = await this.baseRepository_.serialize<
|
||||||
ApiKeyTypes.ApiKeyDTO | ApiKeyTypes.ApiKeyDTO[]
|
ApiKeyTypes.ApiKeyDTO[]
|
||||||
>(createdApiKeys, {
|
>(createdApiKeys, {
|
||||||
populate: true,
|
populate: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// When creating we want to return the raw token, as this will be the only time the user will be able to take note of it for future use.
|
||||||
|
const responseWithRawToken = serializedResponse.map((key) => ({
|
||||||
|
...key,
|
||||||
|
token:
|
||||||
|
generatedTokens.find((t) => t.hashedToken === key.token)?.rawToken ??
|
||||||
|
key.token,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return Array.isArray(data) ? responseWithRawToken : responseWithRawToken[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
@InjectTransactionManager("baseRepository_")
|
@InjectTransactionManager("baseRepository_")
|
||||||
protected async create_(
|
protected async create_(
|
||||||
data: ApiKeyTypes.CreateApiKeyDTO | ApiKeyTypes.CreateApiKeyDTO[],
|
data: ApiKeyTypes.CreateApiKeyDTO[],
|
||||||
@MedusaContext() sharedContext: Context = {}
|
@MedusaContext() sharedContext: Context = {}
|
||||||
): Promise<TEntity | TEntity[]> {
|
): Promise<[TEntity[], TokenDTO[]]> {
|
||||||
const data_ = Array.isArray(data) ? data : [data]
|
await this.validateCreateApiKeys(data, sharedContext)
|
||||||
|
|
||||||
|
const normalizedInput: CreateApiKeyDTO[] = []
|
||||||
|
const generatedTokens: TokenDTO[] = []
|
||||||
|
for (const key of data) {
|
||||||
|
let tokenData: TokenDTO
|
||||||
|
if (key.type === ApiKeyType.PUBLISHABLE) {
|
||||||
|
tokenData = ApiKeyModuleService.generatePublishableKey()
|
||||||
|
} else {
|
||||||
|
tokenData = await ApiKeyModuleService.generateSecretKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
generatedTokens.push(tokenData)
|
||||||
|
normalizedInput.push({
|
||||||
|
...key,
|
||||||
|
token: tokenData.hashedToken,
|
||||||
|
salt: tokenData.salt,
|
||||||
|
redacted: tokenData.redacted,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const createdApiKeys = await this.apiKeyService_.create(
|
const createdApiKeys = await this.apiKeyService_.create(
|
||||||
data_,
|
normalizedInput,
|
||||||
sharedContext
|
sharedContext
|
||||||
)
|
)
|
||||||
|
|
||||||
return Array.isArray(data) ? createdApiKeys : createdApiKeys[0]
|
return [createdApiKeys, generatedTokens]
|
||||||
}
|
}
|
||||||
|
|
||||||
update(
|
update(
|
||||||
@@ -103,31 +142,148 @@ export default class ApiKeyModuleService<TEntity extends ApiKey = ApiKey>
|
|||||||
data: ApiKeyTypes.UpdateApiKeyDTO[] | ApiKeyTypes.UpdateApiKeyDTO,
|
data: ApiKeyTypes.UpdateApiKeyDTO[] | ApiKeyTypes.UpdateApiKeyDTO,
|
||||||
@MedusaContext() sharedContext: Context = {}
|
@MedusaContext() sharedContext: Context = {}
|
||||||
): Promise<ApiKeyTypes.ApiKeyDTO[] | ApiKeyTypes.ApiKeyDTO> {
|
): Promise<ApiKeyTypes.ApiKeyDTO[] | ApiKeyTypes.ApiKeyDTO> {
|
||||||
const updatedApiKeys = await this.update_(data, sharedContext)
|
const updatedApiKeys = await this.update_(
|
||||||
|
Array.isArray(data) ? data : [data],
|
||||||
|
sharedContext
|
||||||
|
)
|
||||||
|
|
||||||
return await this.baseRepository_.serialize<
|
const serializedResponse = await this.baseRepository_.serialize<
|
||||||
ApiKeyTypes.ApiKeyDTO | ApiKeyTypes.ApiKeyDTO[]
|
ApiKeyTypes.ApiKeyDTO[]
|
||||||
>(updatedApiKeys, {
|
>(updatedApiKeys.map(omitToken), {
|
||||||
populate: true,
|
populate: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return Array.isArray(data) ? serializedResponse : serializedResponse[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
@InjectTransactionManager("baseRepository_")
|
@InjectTransactionManager("baseRepository_")
|
||||||
protected async update_(
|
protected async update_(
|
||||||
data: ApiKeyTypes.UpdateApiKeyDTO[] | ApiKeyTypes.UpdateApiKeyDTO,
|
data: ApiKeyTypes.UpdateApiKeyDTO[],
|
||||||
@MedusaContext() sharedContext: Context = {}
|
@MedusaContext() sharedContext: Context = {}
|
||||||
): Promise<TEntity[] | TEntity> {
|
): Promise<TEntity[]> {
|
||||||
return []
|
const updateRequest = data.map((k) => ({
|
||||||
|
id: k.id,
|
||||||
|
title: k.title,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const updatedApiKeys = await this.apiKeyService_.update(
|
||||||
|
updateRequest,
|
||||||
|
sharedContext
|
||||||
|
)
|
||||||
|
return updatedApiKeys
|
||||||
|
}
|
||||||
|
|
||||||
|
@InjectManager("baseRepository_")
|
||||||
|
async retrieve(
|
||||||
|
id: string,
|
||||||
|
config?: FindConfig<ApiKeyTypes.ApiKeyDTO>,
|
||||||
|
sharedContext?: Context
|
||||||
|
): Promise<ApiKeyTypes.ApiKeyDTO> {
|
||||||
|
const apiKey = await this.apiKeyService_.retrieve(id, config, sharedContext)
|
||||||
|
|
||||||
|
return await this.baseRepository_.serialize<ApiKeyTypes.ApiKeyDTO>(
|
||||||
|
omitToken(apiKey),
|
||||||
|
{
|
||||||
|
populate: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@InjectManager("baseRepository_")
|
||||||
|
async list(
|
||||||
|
filters?: ApiKeyTypes.FilterableApiKeyProps,
|
||||||
|
config?: FindConfig<ApiKeyTypes.ApiKeyDTO>,
|
||||||
|
sharedContext?: Context
|
||||||
|
): Promise<ApiKeyTypes.ApiKeyDTO[]> {
|
||||||
|
const apiKeys = await this.apiKeyService_.list(
|
||||||
|
filters,
|
||||||
|
config,
|
||||||
|
sharedContext
|
||||||
|
)
|
||||||
|
|
||||||
|
return this.baseRepository_.serialize<ApiKeyTypes.ApiKeyDTO[]>(
|
||||||
|
apiKeys.map(omitToken),
|
||||||
|
{
|
||||||
|
populate: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@InjectManager("baseRepository_")
|
||||||
|
async listAndCount(
|
||||||
|
filters?: ApiKeyTypes.FilterableApiKeyProps,
|
||||||
|
config?: FindConfig<ApiKeyTypes.ApiKeyDTO>,
|
||||||
|
sharedContext?: Context
|
||||||
|
): Promise<[ApiKeyTypes.ApiKeyDTO[], number]> {
|
||||||
|
const result = await this.apiKeyService_.listAndCount(
|
||||||
|
filters,
|
||||||
|
config,
|
||||||
|
sharedContext
|
||||||
|
)
|
||||||
|
const withoutToken = result[0].map(omitToken)
|
||||||
|
const count = result[1]
|
||||||
|
|
||||||
|
return [
|
||||||
|
await this.baseRepository_.serialize<ApiKeyTypes.ApiKeyDTO[]>(
|
||||||
|
withoutToken,
|
||||||
|
{
|
||||||
|
populate: true,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
count,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
async revoke(
|
||||||
|
data: ApiKeyTypes.RevokeApiKeyDTO[],
|
||||||
|
sharedContext?: Context
|
||||||
|
): Promise<ApiKeyTypes.ApiKeyDTO[]>
|
||||||
|
async revoke(
|
||||||
|
data: ApiKeyTypes.RevokeApiKeyDTO,
|
||||||
|
sharedContext?: Context
|
||||||
|
): Promise<ApiKeyTypes.ApiKeyDTO>
|
||||||
|
|
||||||
|
@InjectManager("baseRepository_")
|
||||||
|
async revoke(
|
||||||
|
data: ApiKeyTypes.RevokeApiKeyDTO[] | ApiKeyTypes.RevokeApiKeyDTO,
|
||||||
|
@MedusaContext() sharedContext: Context = {}
|
||||||
|
): Promise<ApiKeyTypes.ApiKeyDTO[] | ApiKeyTypes.ApiKeyDTO> {
|
||||||
|
const revokedApiKeys = await this.revoke_(
|
||||||
|
Array.isArray(data) ? data : [data],
|
||||||
|
sharedContext
|
||||||
|
)
|
||||||
|
|
||||||
|
const serializedResponse = await this.baseRepository_.serialize<
|
||||||
|
ApiKeyTypes.ApiKeyDTO[]
|
||||||
|
>(revokedApiKeys.map(omitToken), {
|
||||||
|
populate: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return Array.isArray(data) ? serializedResponse : serializedResponse[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
@InjectTransactionManager("baseRepository_")
|
@InjectTransactionManager("baseRepository_")
|
||||||
async revoke(
|
async revoke_(
|
||||||
id: string,
|
data: ApiKeyTypes.RevokeApiKeyDTO[],
|
||||||
@MedusaContext() sharedContext: Context = {}
|
@MedusaContext() sharedContext: Context = {}
|
||||||
): Promise<void> {
|
): Promise<TEntity[]> {
|
||||||
return
|
await this.validateRevokeApiKeys(data)
|
||||||
|
|
||||||
|
const updateRequest = data.map((k) => ({
|
||||||
|
id: k.id,
|
||||||
|
revoked_at: new Date(),
|
||||||
|
revoked_by: k.revoked_by,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const revokedApiKeys = await this.apiKeyService_.update(
|
||||||
|
updateRequest,
|
||||||
|
sharedContext
|
||||||
|
)
|
||||||
|
|
||||||
|
return revokedApiKeys
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Implement
|
||||||
@InjectTransactionManager("baseRepository_")
|
@InjectTransactionManager("baseRepository_")
|
||||||
authenticate(
|
authenticate(
|
||||||
id: string,
|
id: string,
|
||||||
@@ -135,4 +291,108 @@ export default class ApiKeyModuleService<TEntity extends ApiKey = ApiKey>
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
return Promise.resolve(false)
|
return Promise.resolve(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected async validateCreateApiKeys(
|
||||||
|
data: ApiKeyTypes.CreateApiKeyDTO[],
|
||||||
|
sharedContext: Context = {}
|
||||||
|
): Promise<void> {
|
||||||
|
if (!data.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// There can only be 2 secret keys at most, and one has to be with a revoked_at date set, so only 1 can be newly created.
|
||||||
|
const secretKeysToCreate = data.filter((k) => k.type === ApiKeyType.SECRET)
|
||||||
|
if (secretKeysToCreate.length > 1) {
|
||||||
|
throw new MedusaError(
|
||||||
|
MedusaError.Types.INVALID_DATA,
|
||||||
|
`You can only create one secret key at a time. You tried to create ${secretKeysToCreate.length} secret keys.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// There already is a key that is not set to expire/or it hasn't expired
|
||||||
|
const dbSecretKeys = await this.apiKeyService_.list(
|
||||||
|
{
|
||||||
|
type: ApiKeyType.SECRET,
|
||||||
|
revoked_at: null,
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
sharedContext
|
||||||
|
)
|
||||||
|
|
||||||
|
if (dbSecretKeys.length) {
|
||||||
|
throw new MedusaError(
|
||||||
|
MedusaError.Types.INVALID_DATA,
|
||||||
|
`You can only have one active secret key a time. Revoke or delete your existing key before creating a new one.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async validateRevokeApiKeys(
|
||||||
|
data: ApiKeyTypes.RevokeApiKeyDTO[],
|
||||||
|
sharedContext: Context = {}
|
||||||
|
): Promise<void> {
|
||||||
|
if (!data.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.some((k) => !k.revoked_by)) {
|
||||||
|
throw new MedusaError(
|
||||||
|
MedusaError.Types.INVALID_DATA,
|
||||||
|
`You must provide a revoked_by field when revoking a key.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const revokedApiKeys = await this.apiKeyService_.list(
|
||||||
|
{
|
||||||
|
id: data.map((k) => k.id),
|
||||||
|
type: ApiKeyType.SECRET,
|
||||||
|
revoked_at: { $ne: null },
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
sharedContext
|
||||||
|
)
|
||||||
|
|
||||||
|
if (revokedApiKeys.length) {
|
||||||
|
throw new MedusaError(
|
||||||
|
MedusaError.Types.INVALID_DATA,
|
||||||
|
`There are ${revokedApiKeys.length} secret keys that are already revoked.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// These are public keys, so there is no point hashing them.
|
||||||
|
protected static generatePublishableKey(): TokenDTO {
|
||||||
|
const token = "pk_" + crypto.randomBytes(32).toString("hex")
|
||||||
|
|
||||||
|
return {
|
||||||
|
rawToken: token,
|
||||||
|
hashedToken: token,
|
||||||
|
salt: "",
|
||||||
|
redacted: redactKey(token),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static async generateSecretKey(): Promise<TokenDTO> {
|
||||||
|
const token = "sk_" + crypto.randomBytes(32).toString("hex")
|
||||||
|
const salt = crypto.randomBytes(16).toString("hex")
|
||||||
|
const hashed = ((await scrypt(token, salt, 64)) as Buffer).toString("hex")
|
||||||
|
|
||||||
|
return {
|
||||||
|
rawToken: token,
|
||||||
|
hashedToken: hashed,
|
||||||
|
salt,
|
||||||
|
redacted: redactKey(token),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We are mutating the object here as what microORM relies on non-enumerable fields for serialization, among other things.
|
||||||
|
const omitToken = (key: ApiKey): ApiKey => {
|
||||||
|
key.token = key.type === ApiKeyType.SECRET ? "" : key.token
|
||||||
|
key.salt = ""
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
const redactKey = (key: string): string => {
|
||||||
|
return [key.slice(0, 6), key.slice(-3)].join("***")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,23 @@
|
|||||||
|
import { ApiKeyType } from "@medusajs/types"
|
||||||
import { IEventBusModuleService, Logger } from "@medusajs/types"
|
import { IEventBusModuleService, Logger } from "@medusajs/types"
|
||||||
|
|
||||||
export type InitializeModuleInjectableDependencies = {
|
export type InitializeModuleInjectableDependencies = {
|
||||||
logger?: Logger
|
logger?: Logger
|
||||||
eventBusService?: IEventBusModuleService
|
eventBusService?: IEventBusModuleService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CreateApiKeyDTO = {
|
||||||
|
token: string
|
||||||
|
salt: string
|
||||||
|
redacted: string
|
||||||
|
title: string
|
||||||
|
type: ApiKeyType
|
||||||
|
created_by: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TokenDTO = {
|
||||||
|
rawToken: string
|
||||||
|
hashedToken: string
|
||||||
|
salt: string
|
||||||
|
redacted: string
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
import { BaseFilterable } from "../../dal"
|
import { BaseFilterable } from "../../dal"
|
||||||
|
|
||||||
// TODO:
|
export type ApiKeyType = "secret" | "publishable"
|
||||||
export interface ApiKeyDTO {}
|
export interface ApiKeyDTO {
|
||||||
|
id: string
|
||||||
|
token: string
|
||||||
|
redacted: string
|
||||||
|
title: string
|
||||||
|
type: ApiKeyType
|
||||||
|
last_used_at: Date | null
|
||||||
|
created_by: string
|
||||||
|
created_at: Date
|
||||||
|
revoked_by: string | null
|
||||||
|
revoked_at: Date | null
|
||||||
|
}
|
||||||
|
|
||||||
// TODO:
|
|
||||||
export interface FilterableApiKeyProps
|
export interface FilterableApiKeyProps
|
||||||
extends BaseFilterable<FilterableApiKeyProps> {}
|
extends BaseFilterable<FilterableApiKeyProps> {
|
||||||
|
id?: string | string[]
|
||||||
|
title?: string | string[]
|
||||||
|
type?: ApiKeyType
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
// TODO:
|
import { ApiKeyType } from "../common"
|
||||||
export interface CreateApiKeyDTO {}
|
|
||||||
|
|
||||||
// TODO:
|
export interface CreateApiKeyDTO {
|
||||||
export interface UpdateApiKeyDTO {}
|
title: string
|
||||||
|
type: ApiKeyType
|
||||||
|
created_by: string
|
||||||
|
// We could add revoked_at as a parameter (or expires_at that gets mapped to revoked_at internally) in order to support expiring tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateApiKeyDTO {
|
||||||
|
id: string
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevokeApiKeyDTO {
|
||||||
|
id: string
|
||||||
|
revoked_by: string
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { IModuleService } from "../modules-sdk"
|
|||||||
import { ApiKeyDTO, FilterableApiKeyProps } from "./common"
|
import { ApiKeyDTO, FilterableApiKeyProps } from "./common"
|
||||||
import { FindConfig } from "../common"
|
import { FindConfig } from "../common"
|
||||||
import { Context } from "../shared-context"
|
import { Context } from "../shared-context"
|
||||||
import { CreateApiKeyDTO, UpdateApiKeyDTO } from "./mutations"
|
import { CreateApiKeyDTO, RevokeApiKeyDTO, UpdateApiKeyDTO } from "./mutations"
|
||||||
|
|
||||||
export interface IApiKeyModuleService extends IModuleService {
|
export interface IApiKeyModuleService extends IModuleService {
|
||||||
/**
|
/**
|
||||||
@@ -67,10 +67,11 @@ export interface IApiKeyModuleService extends IModuleService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Revokes an api key
|
* Revokes an api key
|
||||||
* @param id
|
* @param data
|
||||||
* @param sharedContext
|
* @param sharedContext
|
||||||
*/
|
*/
|
||||||
revoke(id: string, sharedContext?: Context): Promise<void>
|
revoke(data: RevokeApiKeyDTO[], sharedContext?: Context): Promise<ApiKeyDTO[]>
|
||||||
|
revoke(data: RevokeApiKeyDTO, sharedContext?: Context): Promise<ApiKeyDTO>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check the validity of an api key
|
* Check the validity of an api key
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* @enum
|
||||||
|
*
|
||||||
|
* The API key's type.
|
||||||
|
*/
|
||||||
|
export enum ApiKeyType {
|
||||||
|
/**
|
||||||
|
* Publishable key that is tied to eg. a sales channel
|
||||||
|
*/
|
||||||
|
PUBLISHABLE = "publishable",
|
||||||
|
/**
|
||||||
|
* Secret key that allows access to the admin API
|
||||||
|
*/
|
||||||
|
SECRET = "secret",
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./api-key-type"
|
||||||
@@ -11,3 +11,4 @@ export * as ProductUtils from "./product"
|
|||||||
export * as PromotionUtils from "./promotion"
|
export * as PromotionUtils from "./promotion"
|
||||||
export * as SearchUtils from "./search"
|
export * as SearchUtils from "./search"
|
||||||
export * as ShippingProfileUtils from "./shipping"
|
export * as ShippingProfileUtils from "./shipping"
|
||||||
|
export * as ApiKeyUtils from "./api-key"
|
||||||
|
|||||||
@@ -19,5 +19,6 @@ export * from "./search"
|
|||||||
export * from "./shipping"
|
export * from "./shipping"
|
||||||
export * from "./totals"
|
export * from "./totals"
|
||||||
export * from "./totals/big-number"
|
export * from "./totals/big-number"
|
||||||
|
export * from "./api-key"
|
||||||
|
|
||||||
export const MedusaModuleType = Symbol.for("MedusaModule")
|
export const MedusaModuleType = Symbol.for("MedusaModule")
|
||||||
|
|||||||
Reference in New Issue
Block a user